mirror of
https://github.com/symfony/ux-translator.git
synced 2026-03-24 00:12:19 +01:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a56d25237 | ||
|
|
adf6c0919e | ||
|
|
620b9e5669 | ||
|
|
89233caf4c | ||
|
|
0f67df2893 | ||
|
|
b5f691f37c | ||
|
|
12d5ad4739 | ||
|
|
359e337791 | ||
|
|
2254e170c4 | ||
|
|
32f621ab10 | ||
|
|
838aaf22e7 | ||
|
|
82a5b0245d | ||
|
|
f522fb6cb3 | ||
|
|
20eabecb60 | ||
|
|
70aa6495d4 | ||
|
|
fde719a879 | ||
|
|
9472902436 | ||
|
|
738bc91d5a | ||
|
|
4915996a67 | ||
|
|
ad5d349143 | ||
|
|
5a161e5daa | ||
|
|
2ff4d93cf4 | ||
|
|
237ff2f8ad | ||
|
|
b4b323fdc8 | ||
|
|
15b1f8fac8 | ||
|
|
455bd23aa3 | ||
|
|
8c8ce288a4 | ||
|
|
b6089bad21 | ||
|
|
1b81411a62 | ||
|
|
2ea2b18abe | ||
|
|
9616091db2 | ||
|
|
d79e1f241e | ||
|
|
c300b9f41e | ||
|
|
459b69936c | ||
|
|
f79b5ea73b | ||
|
|
7b490265d9 | ||
|
|
5d15a6c4ee | ||
|
|
432487d0e2 | ||
|
|
5654f0d88d | ||
|
|
b34798feab | ||
|
|
36d90e2035 | ||
|
|
3871e849be | ||
|
|
511f6f98ff |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -3,5 +3,7 @@
|
||||
/phpunit.xml.dist export-ignore
|
||||
/assets/src export-ignore
|
||||
/assets/test export-ignore
|
||||
/assets/playwright.config.ts export-ignore
|
||||
/assets/vitest.config.mjs export-ignore
|
||||
/doc export-ignore
|
||||
/tests export-ignore
|
||||
|
||||
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,5 +1,4 @@
|
||||
Please do not submit any Pull Requests here. They will be closed.
|
||||
---
|
||||
## Please do not submit any Pull Requests here. They will be closed.
|
||||
|
||||
Please submit your PR here instead:
|
||||
https://github.com/symfony/ux
|
||||
|
||||
30
.github/workflows/close-pull-request.yml
vendored
30
.github/workflows/close-pull-request.yml
vendored
@@ -1,20 +1,20 @@
|
||||
name: Close Pull Request
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: superbrothers/close-pull-request@v3
|
||||
with:
|
||||
comment: |
|
||||
Thanks for your Pull Request! We love contributions.
|
||||
|
||||
However, you should instead open your PR on the main repository:
|
||||
https://github.com/symfony/ux
|
||||
|
||||
This repository is what we call a "subtree split": a read-only subset of that main repository.
|
||||
We're looking forward to your PR there!
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: superbrothers/close-pull-request@v3
|
||||
with:
|
||||
comment: |
|
||||
Thanks for your Pull Request! We love contributions.
|
||||
|
||||
However, you should instead open your PR on the main repository:
|
||||
https://github.com/symfony/ux
|
||||
|
||||
This repository is what we call a "subtree split": a read-only subset of that main repository.
|
||||
We're looking forward to your PR there!
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
/assets/node_modules/
|
||||
/config/reference.php
|
||||
/vendor/
|
||||
/composer.lock
|
||||
/phpunit.xml
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
branches: ["2.x"]
|
||||
maintained_branches: ["2.x"]
|
||||
doc_dir: "doc"
|
||||
branches: ['2.x']
|
||||
maintained_branches: ['2.x']
|
||||
doc_dir: 'doc'
|
||||
|
||||
93
CHANGELOG.md
93
CHANGELOG.md
@@ -1,35 +1,108 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.32
|
||||
|
||||
- **[BC BREAK]** Refactor API to use string-based translation keys instead of generated constants.
|
||||
|
||||
Translation keys are now simple strings instead of TypeScript constants.
|
||||
The main advantages are:
|
||||
- You can now use **exactly the same translation keys** as in your Symfony PHP code
|
||||
- Simpler and more readable code
|
||||
- No need to memorize generated constant names
|
||||
- No need to import translation constants: smaller files
|
||||
- And you can still get autocompletion and type-safety :rocket:
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
import { trans } from '@symfony/ux-translator';
|
||||
import { SYMFONY_GREAT } from '@app/translations';
|
||||
|
||||
trans(SYMFONY_GREAT);
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { createTranslator } from '@symfony/ux-translator';
|
||||
import { messages } from '../var/translations/index.js';
|
||||
|
||||
const { trans } = createTranslator({ messages });
|
||||
trans('symfony.great');
|
||||
```
|
||||
|
||||
The global functions (`setLocale`, `getLocale`, `setLocaleFallbacks`, `getLocaleFallbacks`, `throwWhenNotFound`)
|
||||
have been replaced by a new `createTranslator()` factory function that returns an object with these methods.
|
||||
|
||||
**Tree-shaking:** While tree-shaking of individual translation keys is no longer possible, modern build tools,
|
||||
caching strategies, and compression techniques (Brotli, gzip) make this negligible in 2025.
|
||||
You can use the `keys_patterns` configuration option to filter dumped translations by pattern
|
||||
for further reducing bundle size.
|
||||
|
||||
**For AssetMapper users:** You can remove the following entries from your `importmap.php`:
|
||||
|
||||
```php
|
||||
'@app/translations' => [
|
||||
'path' => './var/translations/index.js',
|
||||
],
|
||||
'@app/translations/configuration' => [
|
||||
'path' => './var/translations/configuration.js',
|
||||
],
|
||||
```
|
||||
|
||||
**Note:** This is a breaking change, but the UX Translator component is still experimental.
|
||||
|
||||
- **[BC BREAK]** Refactor `TranslationsDumper` to accept configuration options via `dump()` method parameters,
|
||||
instead of constructor arguments or method calls:
|
||||
- Removed `$dumpDir` and `$dumpTypeScript` constructor arguments
|
||||
- Removed `TranslationsDumper::addIncludedDomain()` and `TranslationsDumper::addExcludedDomain()` methods
|
||||
|
||||
**Note:** This is a breaking change, but the UX Translator component is still experimental.
|
||||
|
||||
- Add configuration `ux_translator.dump_typescript` to enable/disable TypeScript types dumping,
|
||||
default to `true`. Generating TypeScript types is useful when developing,
|
||||
but not in production when using the AssetMapper (which does not use these types).
|
||||
|
||||
- Add `keys_patterns` configuration option to filter dumped translations by key patterns (e.g., `app.*`, `!*.internal`)
|
||||
|
||||
## 2.30
|
||||
|
||||
- Ensure compatibility with PHP 8.5
|
||||
|
||||
## 2.29.0
|
||||
|
||||
- Add Symfony 8 support
|
||||
|
||||
## 2.22.0
|
||||
|
||||
- Support both the Symfony format (`fr_FR`) and W3C specification (`fr-FR`) for locale subcodes.
|
||||
- Support both the Symfony format (`fr_FR`) and W3C specification (`fr-FR`) for locale subcodes.
|
||||
|
||||
## 2.20.0
|
||||
|
||||
- Add `throwWhenNotFound` function to configure the behavior when a translation is not found.
|
||||
- Add `throwWhenNotFound` function to configure the behavior when a translation is not found.
|
||||
|
||||
## 2.19.0
|
||||
|
||||
- Add configuration to filter dumped translations by domain.
|
||||
- Add configuration to filter dumped translations by domain.
|
||||
|
||||
## 2.16.0
|
||||
|
||||
- Increase version range of `intl-messageformat` to `^10.5.11`, in order to see
|
||||
a faster implementation of ICU messages parsing. #1443
|
||||
- Increase version range of `intl-messageformat` to `^10.5.11`, in order to see
|
||||
a faster implementation of ICU messages parsing. #1443
|
||||
|
||||
## 2.13.2
|
||||
|
||||
- Revert "Change JavaScript package to `type: module`"
|
||||
- Revert "Change JavaScript package to `type: module`"
|
||||
|
||||
## 2.13.0
|
||||
|
||||
- Add Symfony 7 support.
|
||||
- Change JavaScript package to `type: module`
|
||||
- Add Symfony 7 support.
|
||||
- Change JavaScript package to `type: module`
|
||||
|
||||
## 2.9.0
|
||||
|
||||
- Add support for symfony/asset-mapper
|
||||
- Add support for symfony/asset-mapper
|
||||
|
||||
## 2.8.0
|
||||
|
||||
- Component added
|
||||
- Component added
|
||||
|
||||
@@ -10,7 +10,7 @@ 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)
|
||||
- [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)
|
||||
|
||||
@@ -6,19 +6,20 @@ JavaScript assets of the [symfony/ux-translator](https://packagist.org/packages/
|
||||
|
||||
This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.).
|
||||
|
||||
We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled.
|
||||
We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled.
|
||||
|
||||
If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) PHP package version:
|
||||
|
||||
```shell
|
||||
composer require symfony/ux-translator:2.23.0
|
||||
npm add @symfony/ux-translator@2.23.0
|
||||
```
|
||||
|
||||
**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`.
|
||||
**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config --json "extra.symfony/flex.synchronize_package_json" false`.
|
||||
|
||||
## 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)
|
||||
- [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)
|
||||
|
||||
39
assets/dist/translator_controller.d.ts
vendored
39
assets/dist/translator_controller.d.ts
vendored
@@ -1,7 +1,8 @@
|
||||
type MessageId = string;
|
||||
type DomainType = string;
|
||||
type LocaleType = string;
|
||||
type TranslationsType = Record<DomainType, {
|
||||
parameters: ParametersType;
|
||||
parameters: ParametersType;
|
||||
}>;
|
||||
type NoParametersType = Record<string, never>;
|
||||
type ParametersType = Record<string, string | number | Date> | NoParametersType;
|
||||
@@ -9,21 +10,27 @@ type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
|
||||
type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
|
||||
type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
|
||||
type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType> ? Translations[D] extends {
|
||||
parameters: infer Parameters;
|
||||
parameters: infer Parameters;
|
||||
} ? Parameters : never : never;
|
||||
interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
|
||||
id: string;
|
||||
translations: {
|
||||
[domain in DomainType]: {
|
||||
[locale in Locale]: string;
|
||||
};
|
||||
};
|
||||
translations: { [domain in DomainType]: { [locale in Locale]: string } };
|
||||
}
|
||||
declare function setLocale(locale: LocaleType | null): void;
|
||||
declare function getLocale(): LocaleType;
|
||||
declare function throwWhenNotFound(enabled: boolean): void;
|
||||
declare function setLocaleFallbacks(localeFallbacks: Record<LocaleType, LocaleType>): void;
|
||||
declare function getLocaleFallbacks(): Record<LocaleType, LocaleType>;
|
||||
declare function trans<M extends Message<TranslationsType, LocaleType>, D extends DomainsOf<M>, P extends ParametersOf<M, D>>(...args: P extends NoParametersType ? [message: M, parameters?: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>] : [message: M, parameters: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>]): string;
|
||||
|
||||
export { type DomainType, type DomainsOf, type LocaleOf, type LocaleType, type Message, type NoParametersType, type ParametersOf, type ParametersType, type RemoveIntlIcuSuffix, type TranslationsType, getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, throwWhenNotFound, trans };
|
||||
type Messages = Record<MessageId, Message<TranslationsType, LocaleType>>;
|
||||
declare function getDefaultLocale(): LocaleType;
|
||||
declare function createTranslator<TMessages extends Messages>({
|
||||
messages,
|
||||
locale,
|
||||
localeFallbacks,
|
||||
throwWhenNotFound
|
||||
}: {
|
||||
messages: TMessages;
|
||||
locale?: LocaleType;
|
||||
localeFallbacks?: Record<LocaleType, LocaleType>;
|
||||
throwWhenNotFound?: boolean;
|
||||
}): {
|
||||
setLocale(locale: LocaleType): void;
|
||||
getLocale(): LocaleType;
|
||||
setThrowWhenNotFound(throwWhenNotFound: boolean): void;
|
||||
trans<TMessageId extends keyof TMessages & MessageId, TMessage extends TMessages[TMessageId], TDomain extends DomainsOf<TMessage>, TParameters extends ParametersOf<TMessage, TDomain>>(id: TMessageId, parameters?: TParameters, domain?: RemoveIntlIcuSuffix<TDomain> | undefined, locale?: LocaleOf<TMessage>): string;
|
||||
};
|
||||
export { DomainType, DomainsOf, LocaleOf, LocaleType, Message, MessageId, Messages, NoParametersType, ParametersOf, ParametersType, RemoveIntlIcuSuffix, TranslationsType, createTranslator, getDefaultLocale };
|
||||
434
assets/dist/translator_controller.js
vendored
434
assets/dist/translator_controller.js
vendored
@@ -1,259 +1,195 @@
|
||||
// src/utils.ts
|
||||
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());
|
||||
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());
|
||||
}
|
||||
|
||||
// src/formatters/formatter.ts
|
||||
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 = /^(?<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, "|");
|
||||
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);
|
||||
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 = /^(?<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, "|");
|
||||
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 _locale2 = locale;
|
||||
if (locale === "pt_BR" || locale === "en_US_POSIX") {
|
||||
return 0;
|
||||
}
|
||||
_locale2 = _locale2.length > 3 ? _locale2.substring(0, _locale2.indexOf("_")) : _locale2;
|
||||
switch (_locale2) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// src/formatters/intl-formatter.ts
|
||||
import { IntlMessageFormat } from "intl-messageformat";
|
||||
function formatIntl(id, parameters, locale) {
|
||||
if (id === "") {
|
||||
return "";
|
||||
}
|
||||
const intlMessage = new IntlMessageFormat(id, [locale.replace("_", "-")], void 0, { 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);
|
||||
if (id === "") return "";
|
||||
const intlMessage = new IntlMessageFormat(id, [locale.replace("_", "-")], void 0, { 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);
|
||||
}
|
||||
|
||||
// src/translator_controller.ts
|
||||
var _locale = null;
|
||||
var _localeFallbacks = {};
|
||||
var _throwWhenNotFound = false;
|
||||
function setLocale(locale) {
|
||||
_locale = locale;
|
||||
function getDefaultLocale() {
|
||||
return document.documentElement.getAttribute("data-symfony-ux-translator-locale") || (document.documentElement.lang ? document.documentElement.lang.replace("-", "_") : null) || "en";
|
||||
}
|
||||
function getLocale() {
|
||||
return _locale || document.documentElement.getAttribute("data-symfony-ux-translator-locale") || // <html data-symfony-ux-translator-locale="en_US">
|
||||
(document.documentElement.lang ? document.documentElement.lang.replace("-", "_") : null) || // <html lang="en-US">
|
||||
"en";
|
||||
function createTranslator({ messages, locale = getDefaultLocale(), localeFallbacks = {}, throwWhenNotFound = false }) {
|
||||
const _messages = messages;
|
||||
const _localeFallbacks = localeFallbacks;
|
||||
let _locale = locale;
|
||||
let _throwWhenNotFound = throwWhenNotFound;
|
||||
function setLocale(locale) {
|
||||
_locale = locale;
|
||||
}
|
||||
function getLocale() {
|
||||
return _locale;
|
||||
}
|
||||
function setThrowWhenNotFound(throwWhenNotFound) {
|
||||
_throwWhenNotFound = throwWhenNotFound;
|
||||
}
|
||||
function trans(id, parameters = {}, domain = "messages", locale = null) {
|
||||
if (typeof domain === "undefined") domain = "messages";
|
||||
if (typeof locale === "undefined" || null === locale) locale = _locale;
|
||||
const message = _messages[id] ?? null;
|
||||
if (message === null) return id;
|
||||
const translationsIntl = message.translations[`${domain}+intl-icu`] ?? void 0;
|
||||
if (typeof translationsIntl !== "undefined") {
|
||||
while (typeof translationsIntl[locale] === "undefined") {
|
||||
locale = _localeFallbacks[locale];
|
||||
if (!locale) break;
|
||||
}
|
||||
if (locale) return formatIntl(translationsIntl[locale], parameters, locale);
|
||||
}
|
||||
const translations = message.translations[domain] ?? void 0;
|
||||
if (typeof translations !== "undefined") {
|
||||
while (typeof translations[locale] === "undefined") {
|
||||
locale = _localeFallbacks[locale];
|
||||
if (!locale) break;
|
||||
}
|
||||
if (locale) return format(translations[locale], parameters, locale);
|
||||
}
|
||||
if (_throwWhenNotFound) throw new Error(`No translation message found with id "${id}".`);
|
||||
return id;
|
||||
}
|
||||
return {
|
||||
setLocale,
|
||||
getLocale,
|
||||
setThrowWhenNotFound,
|
||||
trans
|
||||
};
|
||||
}
|
||||
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
|
||||
};
|
||||
export { createTranslator, getDefaultLocale };
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
"name": "@symfony/ux-translator",
|
||||
"description": "Symfony Translator for JavaScript",
|
||||
"license": "MIT",
|
||||
"version": "2.28.1",
|
||||
"version": "2.34.0",
|
||||
"keywords": [
|
||||
"symfony-ux"
|
||||
],
|
||||
"homepage": "https://ux.symfony.com/translator",
|
||||
"repository": "https://github.com/symfony/ux-translator",
|
||||
"repository": "https://github.com/symfony/ux",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -15,18 +15,17 @@
|
||||
"main": "dist/translator_controller.js",
|
||||
"types": "dist/translator_controller.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsx ../../../bin/build_package.ts .",
|
||||
"watch": "tsx ../../../bin/build_package.ts . --watch",
|
||||
"test": "../../../bin/test_package.sh .",
|
||||
"check": "biome check",
|
||||
"ci": "biome ci"
|
||||
"build": "node ../../../bin/build_package.ts .",
|
||||
"watch": "node ../../../bin/build_package.ts . --watch",
|
||||
"test": "pnpm run test:unit && pnpm run test:browser",
|
||||
"test:unit": "../../../bin/unit_test_package.sh .",
|
||||
"test:browser": "playwright test",
|
||||
"test:browser:ui": "playwright test --ui"
|
||||
},
|
||||
"symfony": {
|
||||
"importmap": {
|
||||
"intl-messageformat": "^10.5.11",
|
||||
"@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js",
|
||||
"@app/translations": "path:var/translations/index.js",
|
||||
"@app/translations/configuration": "path:var/translations/configuration.js"
|
||||
"@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -44,8 +43,7 @@
|
||||
"intl-messageformat": "^10.5.11",
|
||||
"jsdom": "^26.1.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
assets/playwright.config.ts
Normal file
3
assets/playwright.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../playwright.config.base';
|
||||
|
||||
export default baseConfig;
|
||||
@@ -7,168 +7,176 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
export type DomainType = string;
|
||||
export type LocaleType = string;
|
||||
|
||||
export type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
|
||||
export type NoParametersType = Record<string, never>;
|
||||
export type ParametersType = Record<string, string | number | Date> | NoParametersType;
|
||||
|
||||
export type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
|
||||
export type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
|
||||
export type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
|
||||
export type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType>
|
||||
? Translations[D] extends { parameters: infer Parameters }
|
||||
? Parameters
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
|
||||
id: string;
|
||||
translations: {
|
||||
[domain in DomainType]: {
|
||||
[locale in Locale]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
import { format } from './formatters/formatter';
|
||||
import { formatIntl } from './formatters/intl-formatter';
|
||||
import type { DomainsOf, LocaleOf, LocaleType, MessageId, Messages, ParametersOf, RemoveIntlIcuSuffix } from './types';
|
||||
|
||||
let _locale: LocaleType | null = null;
|
||||
let _localeFallbacks: Record<LocaleType, LocaleType> = {};
|
||||
let _throwWhenNotFound = false;
|
||||
export type * from './types.d';
|
||||
|
||||
export function setLocale(locale: LocaleType | null) {
|
||||
_locale = locale;
|
||||
}
|
||||
|
||||
export function getLocale(): LocaleType {
|
||||
export function getDefaultLocale(): LocaleType {
|
||||
return (
|
||||
_locale ||
|
||||
document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // <html data-symfony-ux-translator-locale="en_US">
|
||||
(document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || // <html lang="en-US">
|
||||
'en'
|
||||
);
|
||||
}
|
||||
|
||||
export function throwWhenNotFound(enabled: boolean): void {
|
||||
_throwWhenNotFound = enabled;
|
||||
}
|
||||
export function createTranslator<TMessages extends Messages>({
|
||||
messages,
|
||||
locale = getDefaultLocale(),
|
||||
localeFallbacks = {},
|
||||
throwWhenNotFound = false,
|
||||
}: {
|
||||
messages: TMessages;
|
||||
locale?: LocaleType;
|
||||
localeFallbacks?: Record<LocaleType, LocaleType>;
|
||||
throwWhenNotFound?: boolean;
|
||||
}): {
|
||||
setLocale(locale: LocaleType): void;
|
||||
getLocale(): LocaleType;
|
||||
setThrowWhenNotFound(throwWhenNotFound: boolean): void;
|
||||
trans<
|
||||
TMessageId extends keyof TMessages & MessageId,
|
||||
TMessage extends TMessages[TMessageId],
|
||||
TDomain extends DomainsOf<TMessage>,
|
||||
TParameters extends ParametersOf<TMessage, TDomain>,
|
||||
>(
|
||||
id: TMessageId,
|
||||
parameters?: TParameters,
|
||||
domain?: RemoveIntlIcuSuffix<TDomain> | undefined,
|
||||
locale?: LocaleOf<TMessage>
|
||||
): string;
|
||||
} {
|
||||
const _messages = messages;
|
||||
const _localeFallbacks = localeFallbacks;
|
||||
let _locale = locale;
|
||||
let _throwWhenNotFound = throwWhenNotFound;
|
||||
|
||||
export function setLocaleFallbacks(localeFallbacks: Record<LocaleType, LocaleType>): void {
|
||||
_localeFallbacks = localeFallbacks;
|
||||
}
|
||||
|
||||
export function getLocaleFallbacks(): Record<LocaleType, LocaleType> {
|
||||
return _localeFallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the given message, in ICU format (see https://formatjs.io/docs/intl-messageformat) or Symfony format (see below).
|
||||
*
|
||||
* When a number is provided as a parameter named "%count%", the message is parsed for plural
|
||||
* forms and a translation is chosen according to this number using the following rules:
|
||||
*
|
||||
* Given a message with different plural translations separated by a
|
||||
* pipe (|), this method returns the correct portion of the message based
|
||||
* on the given number, locale and the pluralization rules in the message
|
||||
* itself.
|
||||
*
|
||||
* The message supports two different types of pluralization rules:
|
||||
*
|
||||
* interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples
|
||||
* indexed: There is one apple|There are %count% apples
|
||||
*
|
||||
* The indexed solution can also contain labels (e.g. one: There is one apple).
|
||||
* This is purely for making the translations more clear - it does not
|
||||
* affect the functionality.
|
||||
*
|
||||
* The two methods can also be mixed:
|
||||
* {0} There are no apples|one: There is one apple|more: There are %count% apples
|
||||
*
|
||||
* An interval can represent a finite set of numbers:
|
||||
* {1,2,3,4}
|
||||
*
|
||||
* An interval can represent numbers between two numbers:
|
||||
* [1, +Inf]
|
||||
* ]-1,2[
|
||||
*
|
||||
* The left delimiter can be [ (inclusive) or ] (exclusive).
|
||||
* The right delimiter can be [ (exclusive) or ] (inclusive).
|
||||
* Beside numbers, you can use -Inf and +Inf for the infinite.
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/ISO_31-11
|
||||
*
|
||||
* @param message The message
|
||||
* @param parameters An array of parameters for the message
|
||||
* @param domain The domain for the message or null to use the default
|
||||
* @param locale The locale or null to use the default
|
||||
*/
|
||||
export function trans<
|
||||
M extends Message<TranslationsType, LocaleType>,
|
||||
D extends DomainsOf<M>,
|
||||
P extends ParametersOf<M, D>,
|
||||
>(
|
||||
...args: P extends NoParametersType
|
||||
? [message: M, parameters?: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>]
|
||||
: [message: M, parameters: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>]
|
||||
): string;
|
||||
export function trans<
|
||||
M extends Message<TranslationsType, LocaleType>,
|
||||
D extends DomainsOf<M>,
|
||||
P extends ParametersOf<M, D>,
|
||||
>(
|
||||
message: M,
|
||||
parameters: P = {} as P,
|
||||
domain: RemoveIntlIcuSuffix<DomainsOf<M>> | undefined = 'messages' as RemoveIntlIcuSuffix<DomainsOf<M>>,
|
||||
locale: LocaleOf<M> | null = null
|
||||
): string {
|
||||
if (typeof domain === 'undefined') {
|
||||
domain = 'messages' as RemoveIntlIcuSuffix<DomainsOf<M>>;
|
||||
/**
|
||||
* Sets the locale.
|
||||
*/
|
||||
function setLocale(locale: LocaleType) {
|
||||
_locale = locale;
|
||||
}
|
||||
|
||||
if (typeof locale === 'undefined' || null === locale) {
|
||||
locale = getLocale() as LocaleOf<M>;
|
||||
/**
|
||||
* Returns the current locale.
|
||||
*/
|
||||
function getLocale(): LocaleType {
|
||||
return _locale;
|
||||
}
|
||||
|
||||
if (typeof message.translations === 'undefined') {
|
||||
return message.id;
|
||||
/**
|
||||
* Sets whether an error should be thrown when a translation is not found.
|
||||
*/
|
||||
function setThrowWhenNotFound(throwWhenNotFound: boolean) {
|
||||
_throwWhenNotFound = throwWhenNotFound;
|
||||
}
|
||||
|
||||
const localesFallbacks = getLocaleFallbacks();
|
||||
/**
|
||||
* Translates the given message, in ICU format (see https://formatjs.io/docs/intl-messageformat) or Symfony format (see below).
|
||||
*
|
||||
* When a number is provided as a parameter named "%count%", the message is parsed for plural
|
||||
* forms and a translation is chosen according to this number using the following rules:
|
||||
*
|
||||
* Given a message with different plural translations separated by a
|
||||
* pipe (|), this method returns the correct portion of the message based
|
||||
* on the given number, locale and the pluralization rules in the message
|
||||
* itself.
|
||||
*
|
||||
* The message supports two different types of pluralization rules:
|
||||
*
|
||||
* interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples
|
||||
* indexed: There is one apple|There are %count% apples
|
||||
*
|
||||
* The indexed solution can also contain labels (e.g. one: There is one apple).
|
||||
* This is purely for making the translations more clear - it does not
|
||||
* affect the functionality.
|
||||
*
|
||||
* The two methods can also be mixed:
|
||||
* {0} There are no apples|one: There is one apple|more: There are %count% apples
|
||||
*
|
||||
* An interval can represent a finite set of numbers:
|
||||
* {1,2,3,4}
|
||||
*
|
||||
* An interval can represent numbers between two numbers:
|
||||
* [1, +Inf]
|
||||
* ]-1,2[
|
||||
*
|
||||
* The left delimiter can be [ (inclusive) or ] (exclusive).
|
||||
* The right delimiter can be [ (exclusive) or ] (inclusive).
|
||||
* Beside numbers, you can use -Inf and +Inf for the infinite.
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/ISO_31-11
|
||||
*
|
||||
* @param id The message ID
|
||||
* @param parameters An array of parameters for the message
|
||||
* @param domain The domain for the message or null to use the default
|
||||
* @param locale The locale or null to use the default
|
||||
*/
|
||||
function trans<
|
||||
TMessageId extends keyof TMessages & MessageId,
|
||||
TMessage extends TMessages[TMessageId],
|
||||
TDomain extends DomainsOf<TMessage>,
|
||||
TParameters extends ParametersOf<TMessage, TDomain>,
|
||||
>(
|
||||
id: TMessageId,
|
||||
parameters: TParameters = {} as TParameters,
|
||||
domain: RemoveIntlIcuSuffix<TDomain> | undefined = 'messages' as RemoveIntlIcuSuffix<TDomain>,
|
||||
locale: LocaleOf<TMessage> | null = null
|
||||
): string {
|
||||
if (typeof domain === 'undefined') {
|
||||
domain = 'messages' as RemoveIntlIcuSuffix<TDomain>;
|
||||
}
|
||||
|
||||
const translationsIntl = message.translations[`${domain}+intl-icu`];
|
||||
if (typeof translationsIntl !== 'undefined') {
|
||||
while (typeof translationsIntl[locale] === 'undefined') {
|
||||
locale = localesFallbacks[locale] as LocaleOf<M>;
|
||||
if (!locale) {
|
||||
break;
|
||||
if (typeof locale === 'undefined' || null === locale) {
|
||||
locale = _locale as LocaleOf<TMessage>;
|
||||
}
|
||||
|
||||
const message = _messages[id] ?? (null as TMessage | null);
|
||||
if (message === null) {
|
||||
return id;
|
||||
}
|
||||
|
||||
const translationsIntl = message.translations[`${domain}+intl-icu`] ?? undefined;
|
||||
if (typeof translationsIntl !== 'undefined') {
|
||||
while (typeof translationsIntl[locale] === 'undefined') {
|
||||
locale = _localeFallbacks[locale] as LocaleOf<TMessage>;
|
||||
if (!locale) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
return formatIntl(translationsIntl[locale], parameters, locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
return formatIntl(translationsIntl[locale], parameters, locale);
|
||||
}
|
||||
}
|
||||
const translations = message.translations[domain] ?? undefined;
|
||||
if (typeof translations !== 'undefined') {
|
||||
while (typeof translations[locale] === 'undefined') {
|
||||
locale = _localeFallbacks[locale] as LocaleOf<TMessage>;
|
||||
if (!locale) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const translations = message.translations[domain];
|
||||
if (typeof translations !== 'undefined') {
|
||||
while (typeof translations[locale] === 'undefined') {
|
||||
locale = localesFallbacks[locale] as LocaleOf<M>;
|
||||
if (!locale) {
|
||||
break;
|
||||
if (locale) {
|
||||
return format(translations[locale], parameters, locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
return format(translations[locale], parameters, locale);
|
||||
if (_throwWhenNotFound) {
|
||||
throw new Error(`No translation message found with id "${id}".`);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
if (_throwWhenNotFound) {
|
||||
throw new Error(`No translation message found with id "${message.id}".`);
|
||||
}
|
||||
|
||||
return message.id;
|
||||
return {
|
||||
setLocale,
|
||||
getLocale,
|
||||
setThrowWhenNotFound,
|
||||
trans,
|
||||
};
|
||||
}
|
||||
|
||||
27
assets/src/types.d.ts
vendored
Normal file
27
assets/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
export type MessageId = string;
|
||||
export type DomainType = string;
|
||||
export type LocaleType = string;
|
||||
|
||||
export type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
|
||||
export type NoParametersType = Record<string, never>;
|
||||
export type ParametersType = Record<string, string | number | Date> | NoParametersType;
|
||||
|
||||
export type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
|
||||
export type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
|
||||
export type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
|
||||
export type ParametersOf<M, D extends DomainType> =
|
||||
M extends Message<infer Translations, LocaleType>
|
||||
? Translations[D] extends { parameters: infer Parameters }
|
||||
? Parameters
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
|
||||
translations: {
|
||||
[domain in DomainType]: {
|
||||
[locale in Locale]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type Messages = Record<MessageId, Message<TranslationsType, LocaleType>>;
|
||||
133
assets/test/browser/translator.test.ts
Normal file
133
assets/test/browser/translator.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
function expectOutputToBeEmpty(page: Page) {
|
||||
return expect(page.getByTestId('output')).toBeEmpty();
|
||||
}
|
||||
|
||||
function expectOutputToContainText(page: Page, text: string) {
|
||||
return expect(page.getByTestId('output')).toContainText(text);
|
||||
}
|
||||
|
||||
test('Can translate basic message', async ({ page }) => {
|
||||
await page.goto('/ux-translator/basic');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Hello!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Bonjour !');
|
||||
});
|
||||
|
||||
test('Can translate message with parameter', async ({ page }) => {
|
||||
await page.goto('/ux-translator/with-parameter');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Hello Fabien!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Bonjour Fabien !');
|
||||
|
||||
await page.getByLabel('Name').clear();
|
||||
await page.getByLabel('Name').fill('Hugo');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Hello Hugo!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Bonjour Hugo !');
|
||||
});
|
||||
|
||||
test('Can translate ICU message with `select` argument', async ({ page }) => {
|
||||
await page.goto('/ux-translator/icu-select');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Alex has invited you to her party!');
|
||||
await expectOutputToContainText(page, `🇫🇷 Alex t'a invité à sa fête !`);
|
||||
|
||||
await page.getByLabel('Gender').selectOption({ label: 'Male' });
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Alex has invited you to his party!');
|
||||
await expectOutputToContainText(page, `🇫🇷 Alex t'a invité à sa fête !`);
|
||||
});
|
||||
|
||||
test('Can translate ICU message with `plural` argument', async ({ page }) => {
|
||||
await page.goto('/ux-translator/icu-plural');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 There is one apple...');
|
||||
await expectOutputToContainText(page, '🇫🇷 Il y a une pomme...');
|
||||
|
||||
await page.getByLabel('Apples').clear();
|
||||
await page.getByLabel('Apples').fill('0');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 There are no apples');
|
||||
await expectOutputToContainText(page, `🇫🇷 Il n'y a pas de pommes`);
|
||||
|
||||
await page.getByLabel('Apples').clear();
|
||||
await page.getByLabel('Apples').fill('3');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 There are 3 apples!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Il y a 3 pommes !');
|
||||
});
|
||||
|
||||
test('Can translate ICU message with `selectordinal` argument', async ({ page }) => {
|
||||
await page.goto('/ux-translator/icu-selectordinal');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 You finished 1st!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Tu as terminé 1er !');
|
||||
|
||||
await page.getByLabel('Place').clear();
|
||||
await page.getByLabel('Place').fill('2');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 You finished 2nd!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Tu as terminé 2e !');
|
||||
|
||||
await page.getByLabel('Place').clear();
|
||||
await page.getByLabel('Place').fill('3');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 You finished 3rd!');
|
||||
await expectOutputToContainText(page, '🇫🇷 Tu as terminé 3e !');
|
||||
});
|
||||
|
||||
test('Can translate ICU message with `date` and `time` arguments', async ({ page }) => {
|
||||
await page.goto('/ux-translator/icu-date-time');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Published at 4/27/2023 - 8:12 AM');
|
||||
await expectOutputToContainText(page, '🇫🇷 Publié le 27/04/2023 - 08:12');
|
||||
|
||||
await page.getByLabel('Date').clear();
|
||||
await page.getByLabel('Date').fill('2024-03-17T09:30');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 Published at 3/17/2024 - 9:30 AM');
|
||||
await expectOutputToContainText(page, '🇫🇷 Publié le 17/03/2024 - 09:30');
|
||||
});
|
||||
|
||||
test('Can translate ICU message with `number` and `percent` arguments', async ({ page }) => {
|
||||
await page.goto('/ux-translator/icu-number-percent');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 50% of the work is done');
|
||||
await expectOutputToContainText(page, '🇫🇷 50 % du travail est fait');
|
||||
|
||||
await page.getByLabel('Progress').fill('0.75');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 75% of the work is done');
|
||||
await expectOutputToContainText(page, '🇫🇷 75 % du travail est fait');
|
||||
});
|
||||
|
||||
test('Can translate ICU message with `number` and `currency` arguments', async ({ page }) => {
|
||||
await page.goto('/ux-translator/icu-number-currency');
|
||||
await expectOutputToBeEmpty(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 This artifact is worth €30.00');
|
||||
await expectOutputToContainText(page, '🇫🇷 Cet artéfact vaut 30,00 €');
|
||||
|
||||
await page.getByLabel('Price').clear();
|
||||
await page.getByLabel('Price').fill('12.34');
|
||||
await page.getByRole('button', { name: 'Render' }).click();
|
||||
await expectOutputToContainText(page, '🇬🇧 This artifact is worth €12.34');
|
||||
await expectOutputToContainText(page, '🇫🇷 Cet artéfact vaut 12,34 €');
|
||||
});
|
||||
@@ -1,514 +0,0 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import {
|
||||
getLocale,
|
||||
type Message,
|
||||
type NoParametersType,
|
||||
setLocale,
|
||||
setLocaleFallbacks,
|
||||
throwWhenNotFound,
|
||||
trans,
|
||||
} from '../src/translator_controller';
|
||||
|
||||
describe('Translator', () => {
|
||||
beforeEach(() => {
|
||||
setLocale(null);
|
||||
setLocaleFallbacks({});
|
||||
throwWhenNotFound(false);
|
||||
document.documentElement.lang = '';
|
||||
document.documentElement.removeAttribute('data-symfony-ux-translator-locale');
|
||||
});
|
||||
|
||||
describe('getLocale', () => {
|
||||
test('default locale', () => {
|
||||
// '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('getLocale', () => {
|
||||
test('with subcode', () => {
|
||||
// allow format according to W3C
|
||||
document.documentElement.lang = 'de-AT';
|
||||
expect(getLocale()).toEqual('de_AT');
|
||||
|
||||
// or "incorrect" Symfony locale format
|
||||
document.documentElement.lang = 'de_AT';
|
||||
expect(getLocale()).toEqual('de_AT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLocale', () => {
|
||||
test('custom locale', () => {
|
||||
setLocale('fr');
|
||||
|
||||
expect(getLocale()).toEqual('fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trans', () => {
|
||||
test('basic message', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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(() => {
|
||||
// @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(() => {
|
||||
// @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(() => {
|
||||
// @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(() => {
|
||||
// @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', () => {
|
||||
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', () => {
|
||||
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('missing message should return the message id when `throwWhenNotFound` is false', () => {
|
||||
throwWhenNotFound(false);
|
||||
setLocale('fr');
|
||||
|
||||
const MESSAGE_IN_ANOTHER_DOMAIN: Message<{ security: { parameters: NoParametersType } }, 'en'> = {
|
||||
id: 'Invalid credentials.',
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'Invalid credentials.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(trans(MESSAGE_IN_ANOTHER_DOMAIN)).toEqual('Invalid credentials.');
|
||||
});
|
||||
|
||||
test('missing message should throw an error if `throwWhenNotFound` is true', () => {
|
||||
throwWhenNotFound(true);
|
||||
setLocale('fr');
|
||||
|
||||
const MESSAGE_IN_ANOTHER_DOMAIN: Message<{ security: { parameters: NoParametersType } }, 'en'> = {
|
||||
id: 'Invalid credentials.',
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'Invalid credentials.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
trans(MESSAGE_IN_ANOTHER_DOMAIN);
|
||||
}).toThrow(`No translation message found with id "Invalid credentials.".`);
|
||||
});
|
||||
|
||||
test('message from intl domain should be prioritized over its non-intl equivalent', () => {
|
||||
const MESSAGE: Message<
|
||||
{ 'messages+intl-icu': { parameters: NoParametersType }; messages: { parameters: NoParametersType } },
|
||||
'en'
|
||||
> = {
|
||||
id: 'message',
|
||||
translations: {
|
||||
'messages+intl-icu': {
|
||||
en: 'A intl message',
|
||||
},
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { format } from '../../src/formatters/formatter';
|
||||
import { format } from '../../../src/formatters/formatter';
|
||||
|
||||
describe('Formatter', () => {
|
||||
test.concurrent.each<[string, string, Record<string, string | number>]>([
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { formatIntl } from '../../src/formatters/intl-formatter';
|
||||
import { formatIntl } from '../../../src/formatters/intl-formatter';
|
||||
|
||||
describe('Intl Formatter', () => {
|
||||
test('format with named arguments', () => {
|
||||
621
assets/test/unit/translator_controller.test.ts
Normal file
621
assets/test/unit/translator_controller.test.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createTranslator } from '../../src/translator_controller';
|
||||
import type { Message, NoParametersType } from '../../src/types';
|
||||
|
||||
describe('Translator', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = '';
|
||||
document.documentElement.removeAttribute('data-symfony-ux-translator-locale');
|
||||
});
|
||||
|
||||
describe('create translator with locale', () => {
|
||||
test('default locale', () => {
|
||||
let translator = createTranslator({ messages: {} });
|
||||
|
||||
// 'en' is the default locale
|
||||
expect(translator.getLocale()).toEqual('en');
|
||||
|
||||
// or the locale from <html lang="...">, if exists
|
||||
document.documentElement.lang = 'fr';
|
||||
translator = createTranslator({ messages: {} });
|
||||
expect(translator.getLocale()).toEqual('fr');
|
||||
|
||||
// or the locale from <html data-symfony-ux-translator-locale="...">, if exists
|
||||
document.documentElement.setAttribute('data-symfony-ux-translator-locale', 'it');
|
||||
translator = createTranslator({ messages: {} });
|
||||
expect(translator.getLocale()).toEqual('it');
|
||||
});
|
||||
|
||||
test('custom locale', () => {
|
||||
document.documentElement.lang = 'fr';
|
||||
document.documentElement.setAttribute('data-symfony-ux-translator-locale', 'it');
|
||||
|
||||
const translator = createTranslator({ messages: {}, locale: 'de' });
|
||||
expect(translator.getLocale()).toEqual('de');
|
||||
});
|
||||
|
||||
test('with subcode', () => {
|
||||
// allow format according to W3C
|
||||
document.documentElement.lang = 'de-AT';
|
||||
const translator = createTranslator({ messages: {} });
|
||||
expect(translator.getLocale()).toEqual('de_AT');
|
||||
|
||||
// or "incorrect" Symfony locale format
|
||||
document.documentElement.lang = 'de_AT';
|
||||
expect(translator.getLocale()).toEqual('de_AT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trans', () => {
|
||||
test('basic message', () => {
|
||||
const messages: {
|
||||
'message.basic': Message<{ messages: { parameters: NoParametersType } }, 'en'>;
|
||||
} = {
|
||||
'message.basic': {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'A basic message',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(translator.trans('message.basic')).toEqual('A basic message');
|
||||
expect(translator.trans('message.basic', {})).toEqual('A basic message');
|
||||
expect(translator.trans('message.basic', {}, 'messages')).toEqual('A basic message');
|
||||
expect(translator.trans('message.basic', {}, 'messages', 'en')).toEqual('A basic message');
|
||||
|
||||
// @ts-expect-error "%count%" is not a valid parameter
|
||||
expect(translator.trans('message.basic', { '%count%': 1 })).toEqual('A basic message');
|
||||
|
||||
// @ts-expect-error "foo" is not a valid domain
|
||||
expect(translator.trans('message.basic', {}, 'foo')).toEqual('message.basic');
|
||||
|
||||
// @ts-expect-error "fr" is not a valid locale
|
||||
expect(translator.trans('message.basic', {}, 'messages', 'fr')).toEqual('message.basic');
|
||||
});
|
||||
|
||||
test('basic message with parameters', () => {
|
||||
const messages: {
|
||||
'message.basic.with.parameters': Message<
|
||||
{ messages: { parameters: { '%parameter1%': string; '%parameter2%': string } } },
|
||||
'en'
|
||||
>;
|
||||
} = {
|
||||
'message.basic.with.parameters': {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'A basic message %parameter1% %parameter2%',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(
|
||||
translator.trans('message.basic.with.parameters', {
|
||||
'%parameter1%': 'foo',
|
||||
'%parameter2%': 'bar',
|
||||
})
|
||||
).toEqual('A basic message foo bar');
|
||||
|
||||
expect(
|
||||
translator.trans(
|
||||
'message.basic.with.parameters',
|
||||
{
|
||||
'%parameter1%': 'foo',
|
||||
'%parameter2%': 'bar',
|
||||
},
|
||||
'messages'
|
||||
)
|
||||
).toEqual('A basic message foo bar');
|
||||
|
||||
expect(
|
||||
translator.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(translator.trans('message.basic.with.parameters', {})).toEqual(
|
||||
'A basic message %parameter1% %parameter2%'
|
||||
);
|
||||
|
||||
// @ts-expect-error Parameter "%parameter2%" is missing
|
||||
expect(translator.trans('message.basic.with.parameters', { '%parameter1%': 'foo' })).toEqual(
|
||||
'A basic message foo %parameter2%'
|
||||
);
|
||||
|
||||
expect(
|
||||
translator.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(
|
||||
translator.trans(
|
||||
'message.basic.with.parameters',
|
||||
{
|
||||
'%parameter1%': 'foo',
|
||||
'%parameter2%': 'bar',
|
||||
},
|
||||
'messages',
|
||||
// @ts-expect-error "fr" is not a valid locale
|
||||
'fr'
|
||||
)
|
||||
).toEqual('message.basic.with.parameters');
|
||||
});
|
||||
|
||||
test('intl message', () => {
|
||||
const messages: {
|
||||
'message.intl': Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'>;
|
||||
} = {
|
||||
'message.intl': {
|
||||
translations: {
|
||||
'messages+intl-icu': {
|
||||
en: 'An intl message',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(translator.trans('message.intl')).toEqual('An intl message');
|
||||
expect(translator.trans('message.intl', {})).toEqual('An intl message');
|
||||
expect(translator.trans('message.intl', {}, 'messages')).toEqual('An intl message');
|
||||
expect(translator.trans('message.intl', {}, 'messages', 'en')).toEqual('An intl message');
|
||||
|
||||
// @ts-expect-error "%count%" is not a valid parameter
|
||||
expect(translator.trans('message.intl', { '%count%': 1 })).toEqual('An intl message');
|
||||
|
||||
// @ts-expect-error "foo" is not a valid domain
|
||||
expect(translator.trans('message.intl', {}, 'foo')).toEqual('message.intl');
|
||||
|
||||
// @ts-expect-error "fr" is not a valid locale
|
||||
expect(translator.trans('message.intl', {}, 'messages', 'fr')).toEqual('message.intl');
|
||||
});
|
||||
|
||||
test('intl message with parameters', () => {
|
||||
const messages: {
|
||||
'message.intl.with.parameters': Message<
|
||||
{
|
||||
'messages+intl-icu': {
|
||||
parameters: {
|
||||
gender_of_host: 'male' | 'female' | string;
|
||||
num_guests: number;
|
||||
host: string;
|
||||
guest: string;
|
||||
};
|
||||
};
|
||||
},
|
||||
'en'
|
||||
>;
|
||||
} = {
|
||||
'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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(
|
||||
translator.trans('message.intl.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(
|
||||
translator.trans(
|
||||
'message.intl.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(
|
||||
translator.trans(
|
||||
'message.intl.with.parameters',
|
||||
{
|
||||
gender_of_host: 'female',
|
||||
num_guests: 1,
|
||||
host: 'Lola',
|
||||
guest: 'Hugo',
|
||||
},
|
||||
'messages',
|
||||
'en'
|
||||
)
|
||||
).toEqual('Lola invites Hugo to her party.');
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Parameters "gender_of_host", "num_guests", "host", and "guest" are missing
|
||||
translator.trans('message.intl.with.parameters', {});
|
||||
}).toThrow(/^The intl string context variable "gender_of_host" was not provided/);
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Parameters "num_guests", "host", and "guest" are missing
|
||||
translator.trans('message.intl.with.parameters', {
|
||||
gender_of_host: 'male',
|
||||
});
|
||||
}).toThrow(/^The intl string context variable "num_guests" was not provided/);
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Parameters "host", and "guest" are missing
|
||||
translator.trans('message.intl.with.parameters', {
|
||||
gender_of_host: 'male',
|
||||
num_guests: 123,
|
||||
});
|
||||
}).toThrow(/^The intl string context variable "host" was not provided/);
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Parameter "guest" is missing
|
||||
translator.trans('message.intl.with.parameters', {
|
||||
gender_of_host: 'male',
|
||||
num_guests: 123,
|
||||
host: 'John',
|
||||
});
|
||||
}).toThrow(/^The intl string context variable "guest" was not provided/);
|
||||
|
||||
expect(
|
||||
translator.trans(
|
||||
'message.intl.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(
|
||||
translator.trans(
|
||||
'message.intl.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', () => {
|
||||
const messages: {
|
||||
'message.multi_domains': Message<
|
||||
{ foobar: { parameters: NoParametersType }; messages: { parameters: NoParametersType } },
|
||||
'en'
|
||||
>;
|
||||
} = {
|
||||
'message.multi_domains': {
|
||||
translations: {
|
||||
foobar: {
|
||||
en: 'A message from foobar catalogue',
|
||||
},
|
||||
messages: {
|
||||
en: 'A message from messages catalogue',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(translator.trans('message.multi_domains')).toEqual('A message from messages catalogue');
|
||||
expect(translator.trans('message.multi_domains', {})).toEqual('A message from messages catalogue');
|
||||
expect(translator.trans('message.multi_domains', {}, 'messages')).toEqual(
|
||||
'A message from messages catalogue'
|
||||
);
|
||||
expect(translator.trans('message.multi_domains', {}, 'foobar')).toEqual('A message from foobar catalogue');
|
||||
|
||||
expect(translator.trans('message.multi_domains', {}, 'messages', 'en')).toEqual(
|
||||
'A message from messages catalogue'
|
||||
);
|
||||
expect(translator.trans('message.multi_domains', {}, 'foobar', 'en')).toEqual(
|
||||
'A message from foobar catalogue'
|
||||
);
|
||||
|
||||
// @ts-expect-error Domain "acme" is invalid
|
||||
expect(translator.trans('message.multi_domains', {}, 'acme', 'fr')).toEqual('message.multi_domains');
|
||||
|
||||
// @ts-expect-error Locale "fr" is invalid
|
||||
expect(translator.trans('message.multi_domains', {}, 'messages', 'fr')).toEqual('message.multi_domains');
|
||||
|
||||
// @ts-expect-error Locale "fr" is invalid
|
||||
expect(translator.trans('message.multi_domains', {}, 'foobar', 'fr')).toEqual('message.multi_domains');
|
||||
});
|
||||
|
||||
test('same message id for multiple domains, and different parameters', () => {
|
||||
const messages: {
|
||||
'message.multi_domains.different_parameters': Message<
|
||||
{
|
||||
foobar: { parameters: { '%parameter2%': string } };
|
||||
messages: { parameters: { '%parameter1%': string } };
|
||||
},
|
||||
'en'
|
||||
>;
|
||||
} = {
|
||||
'message.multi_domains.different_parameters': {
|
||||
translations: {
|
||||
foobar: {
|
||||
en: 'A message from foobar catalogue with a parameter %parameter2%',
|
||||
},
|
||||
messages: {
|
||||
en: 'A message from messages catalogue with a parameter %parameter1%',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(translator.trans('message.multi_domains.different_parameters', { '%parameter1%': 'foo' })).toEqual(
|
||||
'A message from messages catalogue with a parameter foo'
|
||||
);
|
||||
expect(
|
||||
translator.trans('message.multi_domains.different_parameters', { '%parameter1%': 'foo' }, 'messages')
|
||||
).toEqual('A message from messages catalogue with a parameter foo');
|
||||
expect(
|
||||
translator.trans(
|
||||
'message.multi_domains.different_parameters',
|
||||
{ '%parameter1%': 'foo' },
|
||||
'messages',
|
||||
'en'
|
||||
)
|
||||
).toEqual('A message from messages catalogue with a parameter foo');
|
||||
expect(
|
||||
translator.trans('message.multi_domains.different_parameters', { '%parameter2%': 'foo' }, 'foobar')
|
||||
).toEqual('A message from foobar catalogue with a parameter foo');
|
||||
expect(
|
||||
translator.trans(
|
||||
'message.multi_domains.different_parameters',
|
||||
{ '%parameter2%': 'foo' },
|
||||
'foobar',
|
||||
'en'
|
||||
)
|
||||
).toEqual('A message from foobar catalogue with a parameter foo');
|
||||
|
||||
// @ts-expect-error Parameter "%parameter1%" is missing
|
||||
expect(translator.trans('message.multi_domains.different_parameters', {})).toEqual(
|
||||
'A message from messages catalogue with a parameter %parameter1%'
|
||||
);
|
||||
|
||||
expect(
|
||||
// @ts-expect-error Domain "baz" is invalid
|
||||
translator.trans('message.multi_domains.different_parameters', { '%parameter1%': 'foo' }, 'baz')
|
||||
).toEqual('message.multi_domains.different_parameters');
|
||||
|
||||
expect(
|
||||
translator.trans(
|
||||
'message.multi_domains.different_parameters',
|
||||
{ '%parameter1%': 'foo' },
|
||||
'messages',
|
||||
// @ts-expect-error Locale "fr" is invalid
|
||||
'fr'
|
||||
)
|
||||
).toEqual('message.multi_domains.different_parameters');
|
||||
});
|
||||
|
||||
test('missing message should return the message id when `throwWhenNotFound` is false', () => {
|
||||
const messages: {
|
||||
'message.id': Message<{ security: { parameters: NoParametersType } }, 'en'>;
|
||||
} = {
|
||||
'message.id': {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'Invalid credentials.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
locale: 'fr',
|
||||
throwWhenNotFound: false,
|
||||
});
|
||||
|
||||
expect(translator.trans('message.id')).toEqual('message.id');
|
||||
});
|
||||
|
||||
test('missing message should throw an error if `throwWhenNotFound` is true', () => {
|
||||
const messages: {
|
||||
'message.id': Message<{ security: { parameters: NoParametersType } }, 'en'>;
|
||||
} = {
|
||||
'message.id': {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'Invalid credentials.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
locale: 'fr',
|
||||
throwWhenNotFound: true,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
translator.trans('message.id');
|
||||
}).toThrow(`No translation message found with id "message.id".`);
|
||||
});
|
||||
|
||||
test('message from intl domain should be prioritized over its non-intl equivalent', () => {
|
||||
const messages: {
|
||||
message: Message<
|
||||
{
|
||||
'messages+intl-icu': { parameters: NoParametersType };
|
||||
messages: { parameters: NoParametersType };
|
||||
},
|
||||
'en'
|
||||
>;
|
||||
} = {
|
||||
message: {
|
||||
translations: {
|
||||
'messages+intl-icu': {
|
||||
en: 'A intl message',
|
||||
},
|
||||
messages: {
|
||||
en: 'A basic message',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
expect(translator.trans('message')).toEqual('A intl message');
|
||||
expect(translator.trans('message', {})).toEqual('A intl message');
|
||||
expect(translator.trans('message', {}, 'messages')).toEqual('A intl message');
|
||||
expect(translator.trans('message', {}, 'messages', 'en')).toEqual('A intl message');
|
||||
});
|
||||
|
||||
test('fallback behavior', () => {
|
||||
const messages: {
|
||||
message: Message<{ messages: { parameters: NoParametersType } }, 'en' | 'en_US' | 'fr'>;
|
||||
message_intl: Message<{ messages: { parameters: NoParametersType } }, 'en' | 'en_US' | 'fr'>;
|
||||
message_french_only: Message<{ messages: { parameters: NoParametersType } }, 'fr'>;
|
||||
} = {
|
||||
message: {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'A message in english',
|
||||
en_US: 'A message in english (US)',
|
||||
fr: 'Un message en français',
|
||||
},
|
||||
},
|
||||
},
|
||||
message_intl: {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'A intl message in english',
|
||||
en_US: 'A intl message in english (US)',
|
||||
fr: 'Un message intl en français',
|
||||
},
|
||||
},
|
||||
},
|
||||
message_french_only: {
|
||||
translations: {
|
||||
messages: {
|
||||
fr: 'Un message en français uniquement',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
localeFallbacks: {
|
||||
fr_FR: 'fr',
|
||||
fr: 'en',
|
||||
en_US: 'en',
|
||||
en_GB: 'en',
|
||||
de_DE: 'de',
|
||||
de: 'en',
|
||||
},
|
||||
});
|
||||
|
||||
expect(translator.trans('message', {}, 'messages', 'en')).toEqual('A message in english');
|
||||
expect(translator.trans('message_intl', {}, 'messages', 'en')).toEqual('A intl message in english');
|
||||
expect(translator.trans('message', {}, 'messages', 'en_US')).toEqual('A message in english (US)');
|
||||
expect(translator.trans('message_intl', {}, 'messages', 'en_US')).toEqual('A intl message in english (US)');
|
||||
expect(translator.trans('message', {}, 'messages', 'en_GB' as 'en')).toEqual('A message in english');
|
||||
expect(translator.trans('message_intl', {}, 'messages', 'en_GB' as 'en')).toEqual(
|
||||
'A intl message in english'
|
||||
);
|
||||
|
||||
expect(translator.trans('message', {}, 'messages', 'fr')).toEqual('Un message en français');
|
||||
expect(translator.trans('message_intl', {}, 'messages', 'fr')).toEqual('Un message intl en français');
|
||||
expect(translator.trans('message', {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message en français');
|
||||
expect(translator.trans('message_intl', {}, 'messages', 'fr_FR' as 'fr')).toEqual(
|
||||
'Un message intl en français'
|
||||
);
|
||||
|
||||
expect(translator.trans('message', {}, 'messages', 'de_DE' as 'en')).toEqual('A message in english');
|
||||
expect(translator.trans('message_intl', {}, 'messages', 'de_DE' as 'en')).toEqual(
|
||||
'A intl message in english'
|
||||
);
|
||||
|
||||
expect(translator.trans('message_french_only', {}, 'messages', 'fr')).toEqual(
|
||||
'Un message en français uniquement'
|
||||
);
|
||||
expect(translator.trans('message_french_only', {}, 'messages', 'en' as 'fr')).toEqual(
|
||||
'message_french_only'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destructuring', () => {
|
||||
test('createTranslator returns expected methods', () => {
|
||||
const messages: {
|
||||
'message.basic': Message<{ messages: { parameters: NoParametersType } }, 'en' | 'fr'>;
|
||||
} = {
|
||||
'message.basic': {
|
||||
translations: {
|
||||
messages: {
|
||||
en: 'A basic message',
|
||||
fr: 'Un message basique',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const translator = createTranslator({ messages });
|
||||
|
||||
const { setLocale, getLocale, setThrowWhenNotFound, trans } = translator;
|
||||
|
||||
expect(typeof setLocale).toBe('function');
|
||||
expect(typeof getLocale).toBe('function');
|
||||
expect(typeof setThrowWhenNotFound).toBe('function');
|
||||
expect(typeof trans).toBe('function');
|
||||
|
||||
expect(getLocale()).toEqual('en');
|
||||
expect(trans('message.basic')).toEqual('A basic message');
|
||||
setLocale('fr');
|
||||
expect(getLocale()).toEqual('fr');
|
||||
expect(trans('message.basic')).toEqual('Un message basique');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { strtr } from '../src/utils';
|
||||
import { strtr } from '../../src/utils';
|
||||
|
||||
describe('Utils', () => {
|
||||
test.concurrent.each<[string, string, Record<string, string>]>([
|
||||
3
assets/tsconfig.json
Normal file
3
assets/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.package.json"
|
||||
}
|
||||
4
assets/vitest.config.mjs
Normal file
4
assets/vitest.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
import { mergeConfig } from 'vitest/config';
|
||||
import configShared from '../../../vitest.config.base.mjs';
|
||||
|
||||
export default mergeConfig(configShared, {});
|
||||
@@ -29,16 +29,16 @@
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/console": "^5.4|^6.0|^7.0",
|
||||
"symfony/filesystem": "^5.4|^6.0|^7.0",
|
||||
"symfony/string": "^5.4|^6.0|^7.0",
|
||||
"symfony/translation": "^5.4|^6.0|^7.0"
|
||||
"symfony/console": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/filesystem": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/string": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/translation": "^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/framework-bundle": "^5.4|^6.0|^7.0",
|
||||
"symfony/phpunit-bridge": "^5.2|^6.0|^7.0",
|
||||
"symfony/var-dumper": "^5.4|^6.0|^7.0",
|
||||
"symfony/yaml": "^5.4|^6.0|^7.0"
|
||||
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/phpunit-bridge": "^5.2|^6.0|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/yaml": "^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"extra": {
|
||||
"thanks": {
|
||||
|
||||
@@ -26,12 +26,16 @@ return static function (ContainerConfigurator $container): void {
|
||||
->args([
|
||||
service('translator'),
|
||||
service('ux.translator.translations_dumper'),
|
||||
abstract_arg('dump_directory'),
|
||||
abstract_arg('dump_typescript'),
|
||||
abstract_arg('included_domains'),
|
||||
abstract_arg('excluded_domains'),
|
||||
abstract_arg('keys_patterns'),
|
||||
])
|
||||
->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'),
|
||||
|
||||
178
doc/index.rst
178
doc/index.rst
@@ -12,11 +12,6 @@ The `ICU Message Format`_ is also supported.
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. note::
|
||||
|
||||
This package works best with WebpackEncore. To use it with AssetMapper, see
|
||||
:ref:`Using with AssetMapper <using-with-asset-mapper>`.
|
||||
|
||||
.. caution::
|
||||
|
||||
Before you start, make sure you have `StimulusBundle configured in your app`_.
|
||||
@@ -27,18 +22,6 @@ Install the bundle using Composer and Symfony Flex:
|
||||
|
||||
$ composer require symfony/ux-translator
|
||||
|
||||
If you're using WebpackEncore, install your assets and restart Encore (not
|
||||
needed if you're using AssetMapper):
|
||||
|
||||
.. code-block:: terminal
|
||||
|
||||
$ npm install --force
|
||||
$ npm run watch
|
||||
|
||||
.. note::
|
||||
|
||||
For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-translator npm package`_
|
||||
|
||||
After installing the bundle, the following file should be created, thanks to the Symfony Flex recipe:
|
||||
|
||||
.. code-block:: javascript
|
||||
@@ -54,13 +37,43 @@ After installing the bundle, the following file should be created, thanks to the
|
||||
* If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking.
|
||||
*/
|
||||
|
||||
import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator';
|
||||
import { localeFallbacks } from '../var/translations/configuration';
|
||||
import { createTranslator } from '@symfony/ux-translator';
|
||||
import { messages, localeFallbacks } from '../var/translations/index.js';
|
||||
|
||||
setLocaleFallbacks(localeFallbacks);
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
localeFallbacks,
|
||||
});
|
||||
|
||||
export { trans }
|
||||
export * from '../var/translations';
|
||||
// Allow you to use `import { trans } from './translator';` in your assets
|
||||
export const { trans } = translator;
|
||||
|
||||
Using with WebpackEncore
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you're using WebpackEncore, install your assets and restart Encore (not
|
||||
needed if you're using AssetMapper):
|
||||
|
||||
.. code-block:: terminal
|
||||
|
||||
$ npm install --force
|
||||
$ npm run watch
|
||||
|
||||
.. note::
|
||||
|
||||
For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-translator npm package`_
|
||||
|
||||
Using with AssetMapper
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Using this library with AssetMapper is possible.
|
||||
|
||||
When installing with AssetMapper, Flex will add a new item to your ``importmap.php``
|
||||
file::
|
||||
|
||||
'@symfony/ux-translator' => [
|
||||
'path' => './vendor/symfony/ux-translator/assets/dist/translator_controller.js',
|
||||
],
|
||||
|
||||
Usage
|
||||
-----
|
||||
@@ -68,8 +81,8 @@ Usage
|
||||
When warming up the Symfony cache, your translations will be dumped as JavaScript into the ``var/translations/`` directory.
|
||||
For a better developer experience, TypeScript types definitions are also generated aside those JavaScript files.
|
||||
|
||||
Then, you will be able to import those JavaScript translations in your assets.
|
||||
Don't worry about your final bundle size, only the translations you use will be included in your final bundle, thanks to the `tree shaking <https://webpack.js.org/guides/tree-shaking/>`_.
|
||||
Then, you will be able to import the ``trans()`` function in your assets and use translation keys as simple strings,
|
||||
exactly as you would in your Symfony PHP code.
|
||||
|
||||
Configuring the dumped translations
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -80,21 +93,49 @@ including or excluding translation domains in your ``config/packages/ux_translat
|
||||
.. code-block:: yaml
|
||||
|
||||
ux_translator:
|
||||
domains: ~ # Include all the domains
|
||||
domains: ~ # Include all the domains
|
||||
|
||||
domains: foo # Include only domain 'foo'
|
||||
domains: '!foo' # Include all domains, except 'foo'
|
||||
domains: foo # Include only domain 'foo'
|
||||
domains: '!foo' # Include all domains, except 'foo'
|
||||
|
||||
domains: [foo, bar] # Include only domains 'foo' and 'bar'
|
||||
domains: ['!foo', '!bar'] # Include all domains, except 'foo' and 'bar'
|
||||
domains: [foo, bar] # Include only domains 'foo' and 'bar'
|
||||
domains: ['!foo', '!bar'] # Include all domains, except 'foo' and 'bar'
|
||||
|
||||
You can also filter dumped translations by translation key patterns using wildcards:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
ux_translator:
|
||||
keys_patterns: ['app.*', 'user.*'] # Include only keys starting with 'app.' or 'user.'
|
||||
keys_patterns: ['!*.internal', '!debug.*'] # Exclude keys ending with '.internal' or starting with 'debug.'
|
||||
keys_patterns: ['app.*', '!app.internal.*'] # Include 'app.*' but exclude 'app.internal.*'
|
||||
|
||||
The wildcard ``*`` matches any characters. You can prefix a pattern with ``!`` to exclude keys matching that pattern.
|
||||
|
||||
Disabling TypeScript types dump
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
By default, TypeScript types definitions are generated alongside the dumped JavaScript translations.
|
||||
This provides autocompletion and type-safety when using the ``trans()`` function in your assets.
|
||||
|
||||
Even if they are useful when developing, dumping these TypeScript types is useless in production if you use the
|
||||
AssetMapper, because these files will never be used.
|
||||
|
||||
You can disable the TypeScript types dump by adding the following configuration:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
when@prod:
|
||||
ux_translator:
|
||||
dump_typescript: false
|
||||
|
||||
Configuring the default locale
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
By default, the default locale is ``en`` (English) that you can configure through many ways (in order of priority):
|
||||
|
||||
#. With ``setLocale('de')`` or ``setLocale('de_AT')`` from ``@symfony/ux-translator`` package
|
||||
#. By passing the locale directly to the ``createTranslator()`` function
|
||||
#. With ``setLocale('de')`` or ``setLocale('de_AT')`` from your ``assets/translator.js`` file
|
||||
#. Or with ``<html data-symfony-ux-translator-locale="{{ app.request.locale }}">`` attribute (e.g., ``de_AT`` or ``de`` using Symfony locale format)
|
||||
#. Or with ``<html lang="{{ app.request.locale|replace({ '_': '-' }) }}">`` attribute (e.g., ``de-AT`` or ``de`` following the `W3C specification on language codes`_)
|
||||
|
||||
@@ -103,86 +144,71 @@ Detecting missing translations
|
||||
|
||||
By default, the translator will return the translation key if the translation is missing.
|
||||
|
||||
You can change this behavior by calling ``throwWhenNotFound(true)``:
|
||||
You can change this behavior by calling ``setThrowWhenNotFound(true)``:
|
||||
|
||||
.. code-block:: diff
|
||||
|
||||
// assets/translator.js
|
||||
|
||||
- import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator';
|
||||
+ import { trans, getLocale, setLocale, setLocaleFallbacks, throwWhenNotFound } from '@symfony/ux-translator';
|
||||
import { localeFallbacks } from '../var/translations/configuration';
|
||||
import { createTranslator } from '@symfony/ux-translator';
|
||||
import { messages, localeFallbacks } from '../var/translations/index.js';
|
||||
|
||||
setLocaleFallbacks(localeFallbacks);
|
||||
+ throwWhenNotFound(true)
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
localeFallbacks,
|
||||
+ throwWhenNotFound: true, // either when creating the translator
|
||||
});
|
||||
|
||||
export { trans }
|
||||
export * from '../var/translations';
|
||||
+ // Or later in your code
|
||||
export const { trans, setThrowWhenNotFound } = translator;
|
||||
|
||||
Importing and using translations
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you use the Symfony Flex recipe, you can import the ``trans()`` function and your translations in your assets from the file ``assets/translator.js``.
|
||||
If you use the Symfony Flex recipe, you can import the ``trans()`` function from the file ``assets/translator.js``.
|
||||
|
||||
Translations are available as named exports, by using the translation's id transformed in uppercase snake-case (e.g.: ``my.translation`` becomes ``MY_TRANSLATION``),
|
||||
so you can import them like this:
|
||||
You can then use translation keys as simple strings, exactly as you would in your Symfony PHP code:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
// assets/my_file.js
|
||||
|
||||
import {
|
||||
trans,
|
||||
TRANSLATION_SIMPLE,
|
||||
TRANSLATION_WITH_PARAMETERS,
|
||||
TRANSLATION_MULTI_DOMAINS,
|
||||
TRANSLATION_MULTI_LOCALES,
|
||||
} from './translator';
|
||||
import { trans } from './translator';
|
||||
|
||||
// No parameters, uses the default domain ("messages") and the default locale
|
||||
trans(TRANSLATION_SIMPLE);
|
||||
trans('translation.simple');
|
||||
|
||||
// Two parameters "count" and "foo", uses the default domain ("messages") and the default locale
|
||||
trans(TRANSLATION_WITH_PARAMETERS, { count: 123, foo: 'bar' });
|
||||
trans('translation.with.parameters', { count: 123, foo: 'bar' });
|
||||
|
||||
// No parameters, uses the default domain ("messages") and the default locale
|
||||
trans(TRANSLATION_MULTI_DOMAINS);
|
||||
trans('translation.multi.domains');
|
||||
// Same as above, but uses the "domain2" domain
|
||||
trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain2');
|
||||
trans('translation.multi.domains', {}, 'domain2');
|
||||
// Same as above, but uses the "domain3" domain
|
||||
trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain3');
|
||||
trans('translation.multi.domains', {}, 'domain3');
|
||||
|
||||
// No parameters, uses the default domain ("messages") and the default locale
|
||||
trans(TRANSLATION_MULTI_LOCALES);
|
||||
trans('translation.multi.locales');
|
||||
// Same as above, but uses the "fr" locale
|
||||
trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'fr');
|
||||
trans('translation.multi.locales', {}, 'messages', 'fr');
|
||||
// Same as above, but uses the "it" locale
|
||||
trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'it');
|
||||
trans('translation.multi.locales', {}, 'messages', 'it');
|
||||
|
||||
.. _using-with-asset-mapper:
|
||||
You will get autocompletion and type-safety for translation keys, parameters, domains, and locales.
|
||||
|
||||
Using with AssetMapper
|
||||
----------------------
|
||||
Q&A
|
||||
---
|
||||
|
||||
Using this library with AssetMapper is possible, but is currently experimental
|
||||
and may not be ready yet for production.
|
||||
What about bundle size?
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When installing with AssetMapper, Flex will add a few new items to your ``importmap.php``
|
||||
file. 2 of the new items are::
|
||||
All your translations (extracted from the configured domains) are included in the generated ``var/translations/index.js`` file,
|
||||
which means they will be included in your final JavaScript bundle).
|
||||
|
||||
'@app/translations' => [
|
||||
'path' => 'var/translations/index.js',
|
||||
],
|
||||
'@app/translations/configuration' => [
|
||||
'path' => 'var/translations/configuration.js',
|
||||
],
|
||||
|
||||
These are then imported in your ``assets/translator.js`` file. This setup is
|
||||
very similar to working with WebpackEncore. However, the ``var/translations/index.js``
|
||||
file contains *every* translation in your app, which is not ideal for production
|
||||
and may even leak translations only meant for admin areas. Encore solves this via
|
||||
tree-shaking, but the AssetMapper component does not. There is not, yet, a way to
|
||||
solve this properly with the AssetMapper component.
|
||||
However, modern build tools, caching strategies, and compression techniques (Brotli, gzip)
|
||||
make this negligible in 2025. You can use the ``keys_patterns`` configuration option
|
||||
to filter dumped translations by pattern if you need to further reduce bundle size.
|
||||
|
||||
Backward Compatibility promise
|
||||
------------------------------
|
||||
|
||||
@@ -18,13 +18,25 @@ use Symfony\UX\Translator\TranslationsDumper;
|
||||
/**
|
||||
* @author Hugo Alliaume <hugo@alliau.me>
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
class TranslationsCacheWarmer implements CacheWarmerInterface
|
||||
{
|
||||
/**
|
||||
* @param list<string> $includedDomains
|
||||
* @param list<string> $excludedDomains
|
||||
* @param list<string> $keysPatterns
|
||||
*/
|
||||
public function __construct(
|
||||
private TranslatorBagInterface $translatorBag,
|
||||
private TranslationsDumper $translationsDumper,
|
||||
private string $dumpDir,
|
||||
private bool $dumpTypeScript,
|
||||
private array $includedDomains,
|
||||
private array $excludedDomains,
|
||||
private array $keysPatterns,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,7 +48,12 @@ class TranslationsCacheWarmer implements CacheWarmerInterface
|
||||
public function warmUp(string $cacheDir, ?string $buildDir = null): array
|
||||
{
|
||||
$this->translationsDumper->dump(
|
||||
...$this->translatorBag->getCatalogues()
|
||||
$this->translatorBag->getCatalogues(),
|
||||
$this->dumpDir,
|
||||
$this->dumpTypeScript,
|
||||
$this->includedDomains,
|
||||
$this->excludedDomains,
|
||||
$this->keysPatterns,
|
||||
);
|
||||
|
||||
// No need to preload anything
|
||||
|
||||
@@ -28,7 +28,14 @@ class Configuration implements ConfigurationInterface
|
||||
$rootNode = $treeBuilder->getRootNode();
|
||||
$rootNode
|
||||
->children()
|
||||
->scalarNode('dump_directory')->defaultValue('%kernel.project_dir%/var/translations')->end()
|
||||
->scalarNode('dump_directory')
|
||||
->info('The directory where translations and TypeScript types are dumped.')
|
||||
->defaultValue('%kernel.project_dir%/var/translations')
|
||||
->end()
|
||||
->booleanNode('dump_typescript')
|
||||
->info('Control whether TypeScript types are dumped alongside translations. Disable this if you do not use TypeScript (e.g. in production when using AssetMapper).')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->arrayNode('domains')
|
||||
->info('List of domains to include/exclude from the generated translations. Prefix with a `!` to exclude a domain.')
|
||||
->children()
|
||||
@@ -45,14 +52,14 @@ class Configuration implements ConfigurationInterface
|
||||
->canBeUnset()
|
||||
->beforeNormalization()
|
||||
->ifString()
|
||||
->then(fn ($v) => ['elements' => [$v]])
|
||||
->then(static fn ($v) => ['elements' => [$v]])
|
||||
->end()
|
||||
->beforeNormalization()
|
||||
->ifTrue(function ($v) { return \is_array($v) && is_numeric(key($v)); })
|
||||
->then(function ($v) { return ['elements' => $v]; })
|
||||
->ifTrue(static function ($v) { return \is_array($v) && is_numeric(key($v)); })
|
||||
->then(static function ($v) { return ['elements' => $v]; })
|
||||
->end()
|
||||
->validate()
|
||||
->always(function ($v) {
|
||||
->always(static function ($v) {
|
||||
$isExclusive = null;
|
||||
$elements = [];
|
||||
if (isset($v['type'])) {
|
||||
@@ -82,6 +89,14 @@ class Configuration implements ConfigurationInterface
|
||||
})
|
||||
->end()
|
||||
->end()
|
||||
->arrayNode('keys_patterns')
|
||||
->info('List of translation key patterns to include/exclude from the generated translations. Prefix with a `!` to exclude a pattern. Supports wildcards (e.g., `app.*`, `*.label`).')
|
||||
->scalarPrototype()->end()
|
||||
->beforeNormalization()
|
||||
->ifString()
|
||||
->then(static fn ($v) => [$v])
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ namespace Symfony\UX\Translator\DependencyInjection;
|
||||
use Symfony\Component\AssetMapper\AssetMapperInterface;
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\Extension;
|
||||
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
|
||||
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
|
||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||
|
||||
/**
|
||||
* @author Hugo Alliaume <hugo@alliau.me>
|
||||
@@ -35,15 +35,23 @@ class UxTranslatorExtension extends Extension implements PrependExtensionInterfa
|
||||
$loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config')));
|
||||
$loader->load('services.php');
|
||||
|
||||
$dumperDefinition = $container->getDefinition('ux.translator.translations_dumper');
|
||||
$dumperDefinition->setArgument(0, $config['dump_directory']);
|
||||
$includedDomains = [];
|
||||
$excludedDomains = [];
|
||||
|
||||
if (isset($config['domains'])) {
|
||||
$method = 'inclusive' === $config['domains']['type'] ? 'addIncludedDomain' : 'addExcludedDomain';
|
||||
foreach ($config['domains']['elements'] as $domainName) {
|
||||
$dumperDefinition->addMethodCall($method, [$domainName]);
|
||||
if ('inclusive' === $config['domains']['type']) {
|
||||
$includedDomains = $config['domains']['elements'];
|
||||
} else {
|
||||
$excludedDomains = $config['domains']['elements'];
|
||||
}
|
||||
}
|
||||
|
||||
$cacheWarmerDefinition = $container->getDefinition('ux.translator.cache_warmer.translations_cache_warmer');
|
||||
$cacheWarmerDefinition->setArgument(2, $config['dump_directory']);
|
||||
$cacheWarmerDefinition->setArgument(3, $config['dump_typescript']);
|
||||
$cacheWarmerDefinition->setArgument(4, $includedDomains);
|
||||
$cacheWarmerDefinition->setArgument(5, $excludedDomains);
|
||||
$cacheWarmerDefinition->setArgument(6, $config['keys_patterns'] ?? []);
|
||||
}
|
||||
|
||||
public function prepend(ContainerBuilder $container): void
|
||||
|
||||
@@ -82,12 +82,12 @@ final class IntlMessageParser
|
||||
) {
|
||||
if ($expectingCloseTag) {
|
||||
break;
|
||||
} else {
|
||||
return $this->error(
|
||||
ErrorKind::UNMATCHED_CLOSING_TAG,
|
||||
new Location(clone $this->position, clone $this->position)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->error(
|
||||
ErrorKind::UNMATCHED_CLOSING_TAG,
|
||||
new Location(clone $this->position, clone $this->position)
|
||||
);
|
||||
} elseif (
|
||||
60 === $char /* `<` */
|
||||
&& !$this->ignoreTag
|
||||
@@ -267,11 +267,11 @@ final class IntlMessageParser
|
||||
|| (125 === $ch /* `}` */ && $nestingLevel > 0)
|
||||
) {
|
||||
return null;
|
||||
} else {
|
||||
$this->bump();
|
||||
|
||||
return Utils::fromCodePoint($ch);
|
||||
}
|
||||
|
||||
$this->bump();
|
||||
|
||||
return Utils::fromCodePoint($ch);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -473,32 +473,32 @@ final class IntlMessageParser
|
||||
],
|
||||
'err' => null,
|
||||
];
|
||||
} else {
|
||||
if (0 === s($skeleton)->length()) {
|
||||
return $this->error(ErrorKind::EXPECT_DATE_TIME_SKELETON, $location);
|
||||
}
|
||||
|
||||
$dateTimePattern = $skeleton;
|
||||
|
||||
$style = [
|
||||
'type' => SkeletonType::DATE_TIME,
|
||||
'pattern' => $dateTimePattern,
|
||||
'location' => $styleAndLocation['styleLocation'],
|
||||
'parsedOptions' => [],
|
||||
];
|
||||
|
||||
$type = 'date' === $argType ? Type::DATE : Type::TIME;
|
||||
|
||||
return [
|
||||
'val' => [
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
'location' => $location,
|
||||
'style' => $style,
|
||||
],
|
||||
'err' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (0 === s($skeleton)->length()) {
|
||||
return $this->error(ErrorKind::EXPECT_DATE_TIME_SKELETON, $location);
|
||||
}
|
||||
|
||||
$dateTimePattern = $skeleton;
|
||||
|
||||
$style = [
|
||||
'type' => SkeletonType::DATE_TIME,
|
||||
'pattern' => $dateTimePattern,
|
||||
'location' => $styleAndLocation['styleLocation'],
|
||||
'parsedOptions' => [],
|
||||
];
|
||||
|
||||
$type = 'date' === $argType ? Type::DATE : Type::TIME;
|
||||
|
||||
return [
|
||||
'val' => [
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
'location' => $location,
|
||||
'style' => $style,
|
||||
],
|
||||
'err' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Regular style or no style.
|
||||
@@ -593,21 +593,20 @@ final class IntlMessageParser
|
||||
],
|
||||
'err' => null,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'val' => [
|
||||
'type' => Type::PLURAL,
|
||||
'value' => $value,
|
||||
'offset' => $pluralOffset,
|
||||
'options' => $optionsResult['val'],
|
||||
'pluralType' => 'plural' === $argType ? 'cardinal' : 'ordinal',
|
||||
'location' => $location,
|
||||
],
|
||||
'err' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// no break
|
||||
return [
|
||||
'val' => [
|
||||
'type' => Type::PLURAL,
|
||||
'value' => $value,
|
||||
'offset' => $pluralOffset,
|
||||
'options' => $optionsResult['val'],
|
||||
'pluralType' => 'plural' === $argType ? 'cardinal' : 'ordinal',
|
||||
'location' => $location,
|
||||
],
|
||||
'err' => null,
|
||||
];
|
||||
|
||||
default:
|
||||
return $this->error(
|
||||
ErrorKind::INVALID_ARGUMENT_TYPE,
|
||||
@@ -962,11 +961,11 @@ final class IntlMessageParser
|
||||
$this->bumpTo($index);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$this->bumpTo($this->messageLength);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->bumpTo($this->messageLength);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,11 @@ final class IntlMessageParametersExtractor implements ExtractorInterface
|
||||
{
|
||||
public function extract(string $message): array
|
||||
{
|
||||
// Early return if there is no parameter-like pattern in the message
|
||||
if (!str_contains($message, '{')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parameters = [];
|
||||
|
||||
$intlMessageParser = new IntlMessageParser($message);
|
||||
|
||||
@@ -38,7 +38,7 @@ final class TypeScriptMessageParametersPrinter
|
||||
$value = implode(
|
||||
'|',
|
||||
array_map(
|
||||
fn (string $val) => 'other' === $val ? 'string' : "'".$val."'",
|
||||
static fn (string $val) => 'other' === $val ? 'string' : "'".$val."'",
|
||||
$parameter['values']
|
||||
)
|
||||
);
|
||||
|
||||
@@ -17,8 +17,6 @@ use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtra
|
||||
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
|
||||
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
|
||||
|
||||
use function Symfony\Component\String\s;
|
||||
|
||||
/**
|
||||
* @author Hugo Alliaume <hugo@alliau.me>
|
||||
*
|
||||
@@ -32,12 +30,7 @@ use function Symfony\Component\String\s;
|
||||
*/
|
||||
class TranslationsDumper
|
||||
{
|
||||
private array $excludedDomains = [];
|
||||
private array $includedDomains = [];
|
||||
private array $alreadyGeneratedConstants = [];
|
||||
|
||||
public function __construct(
|
||||
private string $dumpDir,
|
||||
private MessageParametersExtractor $messageParametersExtractor,
|
||||
private IntlMessageParametersExtractor $intlMessageParametersExtractor,
|
||||
private TypeScriptMessageParametersPrinter $typeScriptMessageParametersPrinter,
|
||||
@@ -45,82 +38,124 @@ class TranslationsDumper
|
||||
) {
|
||||
}
|
||||
|
||||
public function dump(MessageCatalogueInterface ...$catalogues): void
|
||||
{
|
||||
$this->filesystem->mkdir($this->dumpDir);
|
||||
$this->filesystem->remove($this->dumpDir.'/index.js');
|
||||
$this->filesystem->remove($this->dumpDir.'/index.d.ts');
|
||||
$this->filesystem->remove($this->dumpDir.'/configuration.js');
|
||||
$this->filesystem->remove($this->dumpDir.'/configuration.d.ts');
|
||||
/**
|
||||
* @param list<MessageCatalogueInterface> $catalogues
|
||||
* @param list<Domain> $includedDomains
|
||||
* @param list<Domain> $excludedDomains
|
||||
* @param list<string> $keysPatterns
|
||||
*/
|
||||
public function dump(
|
||||
array $catalogues,
|
||||
string $dumpDir,
|
||||
bool $dumpTypeScript = true,
|
||||
array $includedDomains = [],
|
||||
array $excludedDomains = [],
|
||||
array $keysPatterns = [],
|
||||
): void {
|
||||
if ($includedDomains && $excludedDomains) {
|
||||
throw new \LogicException('You cannot set both "excluded_domains" and "included_domains" at the same time.');
|
||||
}
|
||||
|
||||
$translationsJs = '';
|
||||
$translationsTs = "import { Message, NoParametersType } from '@symfony/ux-translator';\n\n";
|
||||
$includedKeysPatterns = [];
|
||||
$excludedKeysPatterns = [];
|
||||
foreach ($keysPatterns as $pattern) {
|
||||
if (str_starts_with($pattern, '!')) {
|
||||
$excludedKeysPatterns[] = substr($pattern, 1);
|
||||
} else {
|
||||
$includedKeysPatterns[] = $pattern;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->getTranslations(...$catalogues) as $translationId => $translationsByDomainAndLocale) {
|
||||
$constantName = $this->generateConstantName($translationId);
|
||||
$includeKeysRegex = $this->buildPatternRegex($includedKeysPatterns);
|
||||
$excludeKeysRegex = $this->buildPatternRegex($excludedKeysPatterns);
|
||||
|
||||
$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->mkdir($dumpDir);
|
||||
$this->filesystem->remove($fileIndexJs = $dumpDir.'/index.js');
|
||||
$this->filesystem->remove($fileIndexDts = $dumpDir.'/index.d.ts');
|
||||
|
||||
$this->filesystem->appendToFile(
|
||||
$fileIndexJs,
|
||||
\sprintf(<<<'JS'
|
||||
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
|
||||
|
||||
export const localeFallbacks = %s;
|
||||
export const messages = {
|
||||
|
||||
JS,
|
||||
json_encode($this->getLocaleFallbacks($catalogues), \JSON_THROW_ON_ERROR)
|
||||
));
|
||||
|
||||
if ($dumpTypeScript) {
|
||||
$this->filesystem->appendToFile(
|
||||
$fileIndexDts,
|
||||
<<<'TS'
|
||||
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
|
||||
import { Message, NoParametersType, LocaleType } from '@symfony/ux-translator';
|
||||
|
||||
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
|
||||
export declare const messages: {
|
||||
|
||||
TS
|
||||
);
|
||||
}
|
||||
|
||||
$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';
|
||||
$additions = [];
|
||||
$typescriptAdditions = [];
|
||||
foreach ($this->getTranslations($catalogues, $excludedDomains, $includedDomains, $includeKeysRegex, $excludeKeysRegex) as $translationId => $translationsByDomainAndLocale) {
|
||||
$translationId = str_replace('"', '\\"', $translationId);
|
||||
|
||||
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
|
||||
TS
|
||||
);
|
||||
}
|
||||
$additions[] = \sprintf(
|
||||
' "%s": %s,%s',
|
||||
$translationId,
|
||||
json_encode(['translations' => $translationsByDomainAndLocale], \JSON_THROW_ON_ERROR),
|
||||
"\n"
|
||||
);
|
||||
|
||||
public function addExcludedDomain(string $domain): void
|
||||
{
|
||||
if ($this->includedDomains) {
|
||||
throw new \LogicException('You cannot set both "excluded_domains" and "included_domains" at the same time.');
|
||||
if ($dumpTypeScript) {
|
||||
$typescriptAdditions[] = \sprintf(
|
||||
' "%s": %s;%s',
|
||||
$translationId,
|
||||
$this->getTranslationsTypeScriptTypeDefinition($translationsByDomainAndLocale),
|
||||
"\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->excludedDomains[] = $domain;
|
||||
}
|
||||
|
||||
public function addIncludedDomain(string $domain): void
|
||||
{
|
||||
if ($this->excludedDomains) {
|
||||
throw new \LogicException('You cannot set both "excluded_domains" and "included_domains" at the same time.');
|
||||
$this->filesystem->appendToFile($fileIndexJs, implode('', $additions).'};'."\n");
|
||||
if ($dumpTypeScript) {
|
||||
$this->filesystem->appendToFile($fileIndexDts, implode('', $typescriptAdditions).'};'."\n");
|
||||
}
|
||||
$this->includedDomains[] = $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<MessageCatalogueInterface> $catalogues
|
||||
* @param list<Domain> $excludedDomains
|
||||
* @param list<Domain> $includedDomains
|
||||
*
|
||||
* @return array<MessageId, array<Domain, array<Locale, string>>>
|
||||
*/
|
||||
private function getTranslations(MessageCatalogueInterface ...$catalogues): array
|
||||
private function getTranslations(array $catalogues, array $excludedDomains, array $includedDomains, ?string $includeKeysRegex, ?string $excludeKeysRegex): array
|
||||
{
|
||||
$translations = [];
|
||||
|
||||
foreach ($catalogues as $catalogue) {
|
||||
$locale = $catalogue->getLocale();
|
||||
foreach ($catalogue->getDomains() as $domain) {
|
||||
if (\in_array($domain, $this->excludedDomains, true)) {
|
||||
if (\in_array($domain, $excludedDomains, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->includedDomains && !\in_array($domain, $this->includedDomains, true)) {
|
||||
if ($includedDomains && !\in_array($domain, $includedDomains, true)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($catalogue->all($domain) as $id => $message) {
|
||||
// Filter by keys patterns
|
||||
if ($excludeKeysRegex && preg_match($excludeKeysRegex, $id)) {
|
||||
continue;
|
||||
}
|
||||
if ($includeKeysRegex && !preg_match($includeKeysRegex, $id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$realDomain = $catalogue->has($id, $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX)
|
||||
? $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX
|
||||
: $domain;
|
||||
@@ -168,11 +203,14 @@ TS
|
||||
return \sprintf(
|
||||
'Message<{ %s }, %s>',
|
||||
implode(', ', $typeScriptParametersType),
|
||||
implode('|', array_map(fn (string $locale) => "'$locale'", array_unique($locales))),
|
||||
implode('|', array_map(static fn (string $locale) => "'$locale'", array_unique($locales))),
|
||||
);
|
||||
}
|
||||
|
||||
private function getLocaleFallbacks(MessageCatalogueInterface ...$catalogues): array
|
||||
/**
|
||||
* @param list<MessageCatalogueInterface> $catalogues
|
||||
*/
|
||||
private function getLocaleFallbacks(array $catalogues): array
|
||||
{
|
||||
$localesFallbacks = [];
|
||||
|
||||
@@ -183,17 +221,45 @@ TS
|
||||
return $localesFallbacks;
|
||||
}
|
||||
|
||||
private function generateConstantName(string $translationId): string
|
||||
/**
|
||||
* @param list<string> $patterns
|
||||
*/
|
||||
private function buildPatternRegex(array $patterns): ?string
|
||||
{
|
||||
$translationId = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString();
|
||||
$prefix = 0;
|
||||
do {
|
||||
$constantName = $translationId.($prefix > 0 ? '_'.$prefix : '');
|
||||
++$prefix;
|
||||
} while ($this->alreadyGeneratedConstants[$constantName] ?? false);
|
||||
if ([] === $patterns) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->alreadyGeneratedConstants[$constantName] = true;
|
||||
// Filter empty patterns and deduplicate
|
||||
$patterns = array_reduce($patterns, static function (array $carry, string $pattern) {
|
||||
$trimmed = trim($pattern);
|
||||
if ('' !== $trimmed && !\in_array($trimmed, $carry, true)) {
|
||||
$carry[] = $trimmed;
|
||||
}
|
||||
|
||||
return $constantName;
|
||||
return $carry;
|
||||
}, []);
|
||||
|
||||
if ([] === $patterns) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If a pattern is just '*', match everything
|
||||
if (\in_array('*', $patterns, true)) {
|
||||
return '/^.*$/';
|
||||
}
|
||||
|
||||
$regexPatterns = [];
|
||||
foreach ($patterns as $pattern) {
|
||||
$regexPatterns[] = str_replace('\\*', '.*', preg_quote($pattern, '/'));
|
||||
}
|
||||
|
||||
$compiledRegex = '/^(?:'.implode('|', $regexPatterns).')$/';
|
||||
|
||||
if (false === preg_match($compiledRegex, '')) {
|
||||
throw new \InvalidArgumentException(\sprintf('The patterns "%s" resulted in an invalid regex. Error "%s".', implode('", "', $patterns), preg_last_error_msg()));
|
||||
}
|
||||
|
||||
return $compiledRegex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,26 @@ final class TranslationsCacheWarmerTest extends TestCase
|
||||
])
|
||||
);
|
||||
|
||||
$dumpDir = '/tmp/translations';
|
||||
$dumpTypeScript = true;
|
||||
$includedDomains = [];
|
||||
$excludedDomains = [];
|
||||
$keysPatterns = [];
|
||||
|
||||
$translationsDumperMock = $this->createMock(TranslationsDumper::class);
|
||||
$translationsDumperMock
|
||||
->expects($this->once())
|
||||
->method('dump')
|
||||
->with(...$translatorBag->getCatalogues());
|
||||
->with($translatorBag->getCatalogues(), $dumpDir, $dumpTypeScript, $includedDomains, $excludedDomains, $keysPatterns);
|
||||
|
||||
$translationsCacheWarmer = new TranslationsCacheWarmer(
|
||||
$translatorBag,
|
||||
$translationsDumperMock
|
||||
$translationsDumperMock,
|
||||
$dumpDir,
|
||||
$dumpTypeScript,
|
||||
$includedDomains,
|
||||
$excludedDomains,
|
||||
$keysPatterns,
|
||||
);
|
||||
|
||||
$translationsCacheWarmer->warmUp(self::$cacheDir);
|
||||
|
||||
@@ -35,34 +35,28 @@ class DumpEnabledLocalesTest extends KernelTestCase
|
||||
|
||||
self::assertStringEqualsFile(
|
||||
$translationsDumpDir.'/index.js',
|
||||
<<<JAVASCRIPT
|
||||
export const SYMFONY_UX_GREAT = {"id":"symfony_ux.great","translations":{"messages":{"en":"Symfony UX is awesome","fr":"Symfony UX est g\u00e9nial"}}};
|
||||
<<<JS
|
||||
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
|
||||
|
||||
JAVASCRIPT
|
||||
export const localeFallbacks = {"en":null,"fr":"en"};
|
||||
export const messages = {
|
||||
"symfony_ux.great": {"translations":{"messages":{"en":"Symfony UX is awesome","fr":"Symfony UX est g\u00e9nial"}}},
|
||||
};
|
||||
|
||||
JS
|
||||
);
|
||||
self::assertStringEqualsFile(
|
||||
$translationsDumpDir.'/index.d.ts',
|
||||
<<<TYPESCRIPT
|
||||
import { Message, NoParametersType } from '@symfony/ux-translator';
|
||||
<<<TS
|
||||
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
|
||||
import { Message, NoParametersType, LocaleType } from '@symfony/ux-translator';
|
||||
|
||||
export declare const SYMFONY_UX_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
|
||||
export declare const messages: {
|
||||
"symfony_ux.great": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
};
|
||||
|
||||
TYPESCRIPT
|
||||
);
|
||||
self::assertStringEqualsFile(
|
||||
$translationsDumpDir.'/configuration.js',
|
||||
<<<JAVASCRIPT
|
||||
export const localeFallbacks = {"en":null,"fr":"en"};
|
||||
|
||||
JAVASCRIPT
|
||||
);
|
||||
self::assertStringEqualsFile(
|
||||
$translationsDumpDir.'/configuration.d.ts',
|
||||
<<<TYPESCRIPT
|
||||
import { LocaleType } from '@symfony/ux-translator';
|
||||
|
||||
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
|
||||
TYPESCRIPT
|
||||
TS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +129,12 @@ class IntlMessageParserTest extends TestCase
|
||||
|
||||
yield 'plural' => [
|
||||
<<<'EOT'
|
||||
You have {itemCount, plural,
|
||||
=0 {no items}
|
||||
one {1 item}
|
||||
other {{itemCount} items}
|
||||
}.
|
||||
EOT,
|
||||
You have {itemCount, plural,
|
||||
=0 {no items}
|
||||
one {1 item}
|
||||
other {{itemCount} items}
|
||||
}.
|
||||
EOT,
|
||||
[
|
||||
'val' => [
|
||||
[
|
||||
@@ -198,24 +198,24 @@ EOT,
|
||||
|
||||
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,
|
||||
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' => [
|
||||
[
|
||||
|
||||
@@ -33,7 +33,7 @@ trait AppKernelTrait
|
||||
$dir = sys_get_temp_dir().'/translator_bundle/'.uniqid($type.'_', true);
|
||||
|
||||
if (!file_exists($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
mkdir($dir, 0o777, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
|
||||
@@ -33,7 +33,7 @@ class FrameworkAppKernel extends Kernel
|
||||
|
||||
public function registerContainerConfiguration(LoaderInterface $loader): void
|
||||
{
|
||||
$loader->load(function (ContainerBuilder $container) {
|
||||
$loader->load(static function (ContainerBuilder $container) {
|
||||
$container->loadFromExtension('framework', [
|
||||
'secret' => '$ecret',
|
||||
'test' => true,
|
||||
|
||||
@@ -68,12 +68,12 @@ class IntlMessageParametersExtractorTest extends TestCase
|
||||
|
||||
yield [
|
||||
<<<TXT
|
||||
{gender, select,
|
||||
male {He}
|
||||
female {She}
|
||||
other {They}
|
||||
} will respond shortly.
|
||||
TXT,
|
||||
{gender, select,
|
||||
male {He}
|
||||
female {She}
|
||||
other {They}
|
||||
} will respond shortly.
|
||||
TXT,
|
||||
[
|
||||
'gender' => ['type' => 'string', 'values' => ['male', 'female', 'other']],
|
||||
],
|
||||
@@ -81,11 +81,11 @@ TXT,
|
||||
|
||||
yield [
|
||||
<<<TXT
|
||||
{taxableArea, select,
|
||||
yes {An additional {taxRate, number, percent} tax will be collected.}
|
||||
other {No taxes apply.}
|
||||
}
|
||||
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'],
|
||||
@@ -94,27 +94,27 @@ TXT,
|
||||
|
||||
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, 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'],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
namespace Symfony\UX\Translator\Tests;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
@@ -22,7 +23,6 @@ use Symfony\UX\Translator\TranslationsDumper;
|
||||
class TranslationsDumperTest extends TestCase
|
||||
{
|
||||
protected static $translationsDumpDir;
|
||||
private TranslationsDumper $translationsDumper;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
@@ -34,81 +34,116 @@ class TranslationsDumperTest extends TestCase
|
||||
@rmdir(self::$translationsDumpDir);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
public function testDump()
|
||||
{
|
||||
$this->translationsDumper = new TranslationsDumper(
|
||||
self::$translationsDumpDir,
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDump()
|
||||
{
|
||||
$this->translationsDumper->dump(...self::getMessageCatalogues());
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
);
|
||||
|
||||
$this->assertFileExists(self::$translationsDumpDir.'/index.js');
|
||||
$this->assertFileExists(self::$translationsDumpDir.'/index.d.ts');
|
||||
|
||||
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.js', <<<'JAVASCRIPT'
|
||||
export const NOTIFICATION_COMMENT_CREATED = {"id":"notification.comment_created","translations":{"messages+intl-icu":{"en":"Your post received a comment!","fr":"Votre article a re\u00e7u un commentaire !"}}};
|
||||
export const NOTIFICATION_COMMENT_CREATED_DESCRIPTION = {"id":"notification.comment_created.description","translations":{"messages+intl-icu":{"en":"Your post \"{title}\" has received a new comment. You can read the comment by following <a href=\"{link}\">this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href=\"{link}\">ce lien<\/a>"}}};
|
||||
export const POST_NUM_COMMENTS = {"id":"post.num_comments","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}}","fr":"{count, plural, one {# commentaire} other {# commentaires}}"},"foobar":{"en":"There is 1 comment|There are %count% comments","fr":"Il y a 1 comment|Il y a %count% comments"}}};
|
||||
export const POST_NUM_COMMENTS_1 = {"id":"post.num_comments.","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
|
||||
export const POST_NUM_COMMENTS_2 = {"id":"post.num_comments..","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
|
||||
export const SYMFONY_GREAT = {"id":"symfony.great","translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}};
|
||||
export const SYMFONY_WHAT = {"id":"symfony.what","translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}};
|
||||
export const SYMFONY_WHAT_1 = {"id":"symfony.what!","translations":{"messages":{"en":"Symfony is %what%! (should not conflict with the previous one.)","fr":"Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
|
||||
export const SYMFONY_WHAT_2 = {"id":"symfony.what.","translations":{"messages":{"en":"Symfony is %what%. (should also not conflict with the previous one.)","fr":"Symfony est %what%. (ne doit pas non plus rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
|
||||
export const APPLES_COUNT0 = {"id":"apples.count.0","translations":{"messages":{"en":"There is 1 apple|There are %count% apples","fr":"Il y a 1 pomme|Il y a %count% pommes"}}};
|
||||
export const APPLES_COUNT1 = {"id":"apples.count.1","translations":{"messages":{"en":"{1} There is one apple|]1,Inf] There are %count% apples","fr":"{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}};
|
||||
export const APPLES_COUNT2 = {"id":"apples.count.2","translations":{"messages":{"en":"{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples","fr":"{0} Il n'y a pas de pommes|{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}};
|
||||
export const APPLES_COUNT3 = {"id":"apples.count.3","translations":{"messages":{"en":"one: There is one apple|more: There are %count% apples","fr":"one: Il y a une pomme|more: Il y a %count% pommes"}}};
|
||||
export const APPLES_COUNT4 = {"id":"apples.count.4","translations":{"messages":{"en":"one: There is one apple|more: There are more than one apple","fr":"one: Il y a une pomme|more: Il y a plus d'une pomme"}}};
|
||||
export const WHAT_COUNT1 = {"id":"what.count.1","translations":{"messages":{"en":"{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}};
|
||||
export const WHAT_COUNT2 = {"id":"what.count.2","translations":{"messages":{"en":"{0} There are no %what%|{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{0} Il n'y a pas de %what%|{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}};
|
||||
export const WHAT_COUNT3 = {"id":"what.count.3","translations":{"messages":{"en":"one: There is one %what%|more: There are %count% %what%","fr":"one: Il y a une %what%|more: Il y a %count% %what%"}}};
|
||||
export const WHAT_COUNT4 = {"id":"what.count.4","translations":{"messages":{"en":"one: There is one %what%|more: There are more than one %what%","fr":"one: Il y a une %what%|more: Il y a more than one %what%"}}};
|
||||
export const ANIMAL_DOG_CAT = {"id":"animal.dog-cat","translations":{"messages":{"en":"Dog and cat","fr":"Chien et chat"}}};
|
||||
export const ANIMAL_DOG_CAT_1 = {"id":"animal.dog_cat","translations":{"messages":{"en":"Dog and cat (should not conflict with the previous one)","fr":"Chien et chat (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
|
||||
export const _0STARTS_WITH_NUMERIC = {"id":"0starts.with.numeric","translations":{"messages":{"en":"Key starts with numeric char","fr":"La touche commence par un caract\u00e8re num\u00e9rique"}}};
|
||||
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.js', <<<'JS'
|
||||
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
|
||||
|
||||
JAVASCRIPT);
|
||||
export const localeFallbacks = {"en":null,"fr":null};
|
||||
export const messages = {
|
||||
"notification.comment_created": {"translations":{"messages+intl-icu":{"en":"Your post received a comment!","fr":"Votre article a re\u00e7u un commentaire !"}}},
|
||||
"notification.comment_created.description": {"translations":{"messages+intl-icu":{"en":"Your post \"{title}\" has received a new comment. You can read the comment by following <a href=\"{link}\">this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href=\"{link}\">ce lien<\/a>"}}},
|
||||
"post.num_comments": {"translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}}","fr":"{count, plural, one {# commentaire} other {# commentaires}}"},"foobar":{"en":"There is 1 comment|There are %count% comments","fr":"Il y a 1 comment|Il y a %count% comments"}}},
|
||||
"post.num_comments.": {"translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}},
|
||||
"post.num_comments..": {"translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}},
|
||||
"symfony.great": {"translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}},
|
||||
"symfony.what": {"translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}},
|
||||
"symfony.what!": {"translations":{"messages":{"en":"Symfony is %what%! (should not conflict with the previous one.)","fr":"Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}},
|
||||
"symfony.what.": {"translations":{"messages":{"en":"Symfony is %what%. (should also not conflict with the previous one.)","fr":"Symfony est %what%. (ne doit pas non plus rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}},
|
||||
"apples.count.0": {"translations":{"messages":{"en":"There is 1 apple|There are %count% apples","fr":"Il y a 1 pomme|Il y a %count% pommes"}}},
|
||||
"apples.count.1": {"translations":{"messages":{"en":"{1} There is one apple|]1,Inf] There are %count% apples","fr":"{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}},
|
||||
"apples.count.2": {"translations":{"messages":{"en":"{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples","fr":"{0} Il n'y a pas de pommes|{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}},
|
||||
"apples.count.3": {"translations":{"messages":{"en":"one: There is one apple|more: There are %count% apples","fr":"one: Il y a une pomme|more: Il y a %count% pommes"}}},
|
||||
"apples.count.4": {"translations":{"messages":{"en":"one: There is one apple|more: There are more than one apple","fr":"one: Il y a une pomme|more: Il y a plus d'une pomme"}}},
|
||||
"what.count.1": {"translations":{"messages":{"en":"{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}},
|
||||
"what.count.2": {"translations":{"messages":{"en":"{0} There are no %what%|{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{0} Il n'y a pas de %what%|{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}},
|
||||
"what.count.3": {"translations":{"messages":{"en":"one: There is one %what%|more: There are %count% %what%","fr":"one: Il y a une %what%|more: Il y a %count% %what%"}}},
|
||||
"what.count.4": {"translations":{"messages":{"en":"one: There is one %what%|more: There are more than one %what%","fr":"one: Il y a une %what%|more: Il y a more than one %what%"}}},
|
||||
"animal.dog-cat": {"translations":{"messages":{"en":"Dog and cat","fr":"Chien et chat"}}},
|
||||
"animal.dog_cat": {"translations":{"messages":{"en":"Dog and cat (should not conflict with the previous one)","fr":"Chien et chat (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}},
|
||||
"0starts.with.numeric": {"translations":{"messages":{"en":"Key starts with numeric char","fr":"La touche commence par un caract\u00e8re num\u00e9rique"}}},
|
||||
};
|
||||
|
||||
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.d.ts', <<<'TYPESCRIPT'
|
||||
import { Message, NoParametersType } from '@symfony/ux-translator';
|
||||
JS);
|
||||
|
||||
export declare const NOTIFICATION_COMMENT_CREATED: Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
export declare const NOTIFICATION_COMMENT_CREATED_DESCRIPTION: Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>;
|
||||
export declare const POST_NUM_COMMENTS: Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const POST_NUM_COMMENTS_1: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>;
|
||||
export declare const POST_NUM_COMMENTS_2: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>;
|
||||
export declare const SYMFONY_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
export declare const SYMFONY_WHAT: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
export declare const SYMFONY_WHAT_1: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
export declare const SYMFONY_WHAT_2: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
export declare const APPLES_COUNT0: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const APPLES_COUNT1: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const APPLES_COUNT2: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const APPLES_COUNT3: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const APPLES_COUNT4: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
export declare const WHAT_COUNT1: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const WHAT_COUNT2: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const WHAT_COUNT3: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
|
||||
export declare const WHAT_COUNT4: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
export declare const ANIMAL_DOG_CAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
export declare const ANIMAL_DOG_CAT_1: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
export declare const _0STARTS_WITH_NUMERIC: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.d.ts', <<<'TS'
|
||||
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
|
||||
import { Message, NoParametersType, LocaleType } from '@symfony/ux-translator';
|
||||
|
||||
TYPESCRIPT);
|
||||
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
|
||||
export declare const messages: {
|
||||
"notification.comment_created": Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
"notification.comment_created.description": Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>;
|
||||
"post.num_comments": Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
"post.num_comments.": Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>;
|
||||
"post.num_comments..": Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>;
|
||||
"symfony.great": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
"symfony.what": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
"symfony.what!": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
"symfony.what.": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
"apples.count.0": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
"apples.count.1": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
"apples.count.2": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
"apples.count.3": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
|
||||
"apples.count.4": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
"what.count.1": Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
|
||||
"what.count.2": Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
|
||||
"what.count.3": Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
|
||||
"what.count.4": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
|
||||
"animal.dog-cat": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
"animal.dog_cat": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
"0starts.with.numeric": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
|
||||
};
|
||||
|
||||
TS);
|
||||
}
|
||||
|
||||
public function testShouldNotDumpTypeScriptTypes()
|
||||
{
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
dumpTypeScript: false,
|
||||
);
|
||||
|
||||
$this->assertFileExists(self::$translationsDumpDir.'/index.js');
|
||||
$this->assertFileDoesNotExist(self::$translationsDumpDir.'/index.d.ts');
|
||||
}
|
||||
|
||||
public function testDumpWithExcludedDomains()
|
||||
{
|
||||
$this->translationsDumper->addExcludedDomain('foobar');
|
||||
$this->translationsDumper->dump(...$this->getMessageCatalogues());
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
excludedDomains: ['foobar'],
|
||||
);
|
||||
|
||||
$this->assertFileExists(self::$translationsDumpDir.'/index.js');
|
||||
$this->assertStringNotContainsString('foobar', file_get_contents(self::$translationsDumpDir.'/index.js'));
|
||||
@@ -116,8 +151,18 @@ TYPESCRIPT);
|
||||
|
||||
public function testDumpIncludedDomains()
|
||||
{
|
||||
$this->translationsDumper->addIncludedDomain('messages');
|
||||
$this->translationsDumper->dump(...$this->getMessageCatalogues());
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
includedDomains: ['messages'],
|
||||
);
|
||||
|
||||
$this->assertFileExists(self::$translationsDumpDir.'/index.js');
|
||||
$this->assertStringNotContainsString('foobar', file_get_contents(self::$translationsDumpDir.'/index.js'));
|
||||
@@ -127,16 +172,183 @@ TYPESCRIPT);
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('You cannot set both "excluded_domains" and "included_domains" at the same time.');
|
||||
$this->translationsDumper->addIncludedDomain('foobar');
|
||||
$this->translationsDumper->addExcludedDomain('messages');
|
||||
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
includedDomains: ['foobar'],
|
||||
excludedDomains: ['messages'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testSetBothExcludedAndIncludedDomains()
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('You cannot set both "excluded_domains" and "included_domains" at the same time.');
|
||||
$this->translationsDumper->addExcludedDomain('foobar');
|
||||
$this->translationsDumper->addIncludedDomain('messages');
|
||||
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
includedDomains: ['messages'],
|
||||
excludedDomains: ['foobar'],
|
||||
);
|
||||
}
|
||||
|
||||
public static function keysPatternProvider(): iterable
|
||||
{
|
||||
yield 'included patterns' => [
|
||||
['symfony.*', 'notification.*'],
|
||||
static function (self $test, string $content) {
|
||||
// Should include keys matching patterns
|
||||
$test->assertStringContainsString('symfony.great', $content);
|
||||
$test->assertStringContainsString('symfony.what', $content);
|
||||
$test->assertStringContainsString('notification.comment_created', $content);
|
||||
|
||||
// Should exclude keys not matching patterns
|
||||
$test->assertStringNotContainsString('apples.count', $content);
|
||||
$test->assertStringNotContainsString('animal.dog', $content);
|
||||
$test->assertStringNotContainsString('post.num_comments', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'excluded patterns' => [
|
||||
['!apples.*', '!what.*'],
|
||||
static function (self $test, string $content) {
|
||||
// Should exclude keys matching exclusion patterns
|
||||
$test->assertStringNotContainsString('apples.count', $content);
|
||||
$test->assertStringNotContainsString('what.count', $content);
|
||||
|
||||
// Should include keys not matching exclusion patterns
|
||||
$test->assertStringContainsString('symfony.great', $content);
|
||||
$test->assertStringContainsString('notification.comment_created', $content);
|
||||
$test->assertStringContainsString('animal.dog', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'mixed patterns' => [
|
||||
['symfony.*', 'notification.*', '!*.what*'],
|
||||
static function (self $test, string $content) {
|
||||
// Should include symfony.* but exclude *.what*
|
||||
$test->assertStringContainsString('symfony.great', $content);
|
||||
$test->assertStringNotContainsString('symfony.what', $content);
|
||||
|
||||
// Should include notification.*
|
||||
$test->assertStringContainsString('notification.comment_created', $content);
|
||||
|
||||
// Should exclude keys not in inclusion patterns
|
||||
$test->assertStringNotContainsString('apples.count', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'wildcard patterns' => [
|
||||
['*.count.*'],
|
||||
static function (self $test, string $content) {
|
||||
// Should include keys matching *.count.*
|
||||
$test->assertStringContainsString('apples.count.0', $content);
|
||||
$test->assertStringContainsString('what.count.1', $content);
|
||||
|
||||
// Should exclude keys not matching pattern
|
||||
$test->assertStringNotContainsString('symfony.great', $content);
|
||||
$test->assertStringNotContainsString('notification.comment_created', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'empty pattern array' => [
|
||||
[],
|
||||
static function (self $test, string $content) {
|
||||
// Should include all keys when pattern array is empty
|
||||
$test->assertStringContainsString('symfony.great', $content);
|
||||
$test->assertStringContainsString('symfony.what', $content);
|
||||
$test->assertStringContainsString('notification.comment_created', $content);
|
||||
$test->assertStringContainsString('apples.count', $content);
|
||||
$test->assertStringContainsString('animal.dog', $content);
|
||||
$test->assertStringContainsString('post.num_comments', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'empty string patterns' => [
|
||||
['', 'symfony.*', ''],
|
||||
static function (self $test, string $content) {
|
||||
// Empty strings should be filtered out, only 'symfony.*' should apply
|
||||
$test->assertStringContainsString('symfony.great', $content);
|
||||
$test->assertStringContainsString('symfony.what', $content);
|
||||
|
||||
// Should exclude keys not matching the valid pattern
|
||||
$test->assertStringNotContainsString('apples.count', $content);
|
||||
$test->assertStringNotContainsString('animal.dog', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'malformed exclusion pattern' => [
|
||||
['!'],
|
||||
static function (self $test, string $content) {
|
||||
// A single '!' should be treated as invalid and include all keys
|
||||
$test->assertStringContainsString('symfony.great', $content);
|
||||
$test->assertStringContainsString('notification.comment_created', $content);
|
||||
$test->assertStringContainsString('apples.count', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'patterns with special regex characters in key names' => [
|
||||
['animal.*'],
|
||||
static function (self $test, string $content) {
|
||||
// Should match keys with dashes and underscores
|
||||
$test->assertStringContainsString('animal.dog-cat', $content);
|
||||
$test->assertStringContainsString('animal.dog_cat', $content);
|
||||
|
||||
// Should exclude non-matching keys
|
||||
$test->assertStringNotContainsString('symfony.great', $content);
|
||||
},
|
||||
];
|
||||
|
||||
yield 'pattern matching keys starting with numeric' => [
|
||||
['0starts.*'],
|
||||
static function (self $test, string $content) {
|
||||
// Should match keys starting with numeric characters
|
||||
$test->assertStringContainsString('0starts.with.numeric', $content);
|
||||
|
||||
// Should exclude non-matching keys
|
||||
$test->assertStringNotContainsString('symfony.great', $content);
|
||||
$test->assertStringNotContainsString('apples.count', $content);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider keysPatternProvider
|
||||
*/
|
||||
#[DataProvider('keysPatternProvider')]
|
||||
public function testDumpWithKeysPatterns(array $keysPatterns, callable $assertions)
|
||||
{
|
||||
$translationsDumper = new TranslationsDumper(
|
||||
new MessageParametersExtractor(),
|
||||
new IntlMessageParametersExtractor(),
|
||||
new TypeScriptMessageParametersPrinter(),
|
||||
new Filesystem(),
|
||||
);
|
||||
|
||||
$translationsDumper->dump(
|
||||
catalogues: self::getMessageCatalogues(),
|
||||
dumpDir: self::$translationsDumpDir,
|
||||
keysPatterns: $keysPatterns,
|
||||
);
|
||||
|
||||
$content = file_get_contents(self::$translationsDumpDir.'/index.js');
|
||||
|
||||
$assertions($this, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
$kernel = new FrameworkAppKernel('test', true);
|
||||
$application = new Application($kernel);
|
||||
$application->setAutoExit(false);
|
||||
|
||||
// Trigger Symfony Translator and UX Translator cache warmers
|
||||
$application->run(new StringInput('cache:clear'));
|
||||
$application->run(new StringInput('cache:clear -vv'));
|
||||
|
||||
Reference in New Issue
Block a user