43 Commits
v2.28.2 ... 2.x

Author SHA1 Message Date
github-actions[bot]
5a56d25237 Update versions to 2.34.0 2026-03-22 22:21:50 +00:00
Hugo Alliaume
adf6c0919e [Autocomplete][Chartjs][Cropperjs][Dropzone][LazyImage][LiveComponent][Map][Notify][React][StimulusBundle][Svelte][Swup][TogglePassword][Translator][Turbo][Typed][Vue] Update package.json to 2.33.0
The job that release npm packages failed https://github.com/symfony/ux/actions/runs/23216748609/job/67479440402

it should be fixed by https://github.com/symfony/ux/pull/3400
2026-03-21 23:29:11 +01:00
Hugo Alliaume
620b9e5669 Update tsdown & use @tsdown/css
This update simplifies the tsdown configuration, we do not need our custom plugin to minify CSS anymore (replaced by `css.minify = true`), and same for our hooks that rename the built CSS (replaced by `css.fileName`) 😍
2026-03-20 09:00:50 +01:00
Matthias Krauser
89233caf4c [Translator] Improve performance of dumper under certain condition 2026-03-17 09:23:34 +01:00
Hugo Alliaume
0f67df2893 Update Vitest to ^4.1.0 2026-03-15 08:57:29 +01:00
Hugo Alliaume
b5f691f37c Migrate from tsup (deprecated) to tsdown
tsup is deprecated in favor of tsdown.

Follow https://github.com/symfony/ux/pull/2935, https://github.com/symfony/ux/pull/2944, and many (local) tries were I was not really happy with the files generated by tsdown/rolldown, we are finally having something extra good!

We have the benefits from https://github.com/symfony/ux/pull/2935, https://github.com/symfony/ux/pull/2944, but without their drawbacks. The code correctly follow the `es2022` target and does not do anything weird anymore with static properties (needed by controllers).

I (Claude) added a plugin to remove the region and JSDoc comments (except if they contain `@deprecated``), since I think we want to keep the code non-minified.
2026-02-28 09:33:37 +01:00
Hugo Alliaume
12d5ad4739 Remove tsx dependency and rely on Node.js 22.18.0 native TypeScript runner 2026-02-27 20:17:39 +01:00
Hugo Alliaume
359e337791 Drop Biome.js for oxfmt and oxlint 2026-02-03 23:14:09 +01:00
Hugo Alliaume
2254e170c4 Run PHP-CS-Fixer (no_useless_else & static_lambda) 2026-02-03 22:36:24 +01:00
Hugo Alliaume
32f621ab10 [Autocomplete][Chartjs][Cropperjs][Dropzone][LazyImage][React][StimulusBundle][Svelte][Swup][TogglePassword][Translator][Turbo][Typed][Vue] Use Extension from DependencyInjection instead of HttpKernel 2026-01-31 08:23:54 +01:00
Hugo Alliaume
838aaf22e7 documentation #3293 [Translator] Fix broken link in Translator doc (MrYamous)
This PR was merged into the 2.x branch.

Discussion
----------

[Translator] Fix broken link in `Translator` doc

| Q              | A
| -------------- | ---
| Bug fix?       |
| New feature?   | no
| Deprecations?  | no
| Documentation? | yes
| Issues         | No issue
| License        | MIT

Currently this link is broken in [documentation](https://symfony.com/bundles/ux-translator/current/index.html#installation)

Commits
-------

46f5200d4fb remove installation note about assetmapper
2026-01-17 21:49:37 +01:00
Hugo Alliaume
82a5b0245d Fix npm releases due to repository issue 2026-01-16 23:36:00 +01:00
Hugo Alliaume
f522fb6cb3 Update versions to 2.32.0 2026-01-16 23:35:37 +01:00
MrYamous
20eabecb60 remove installation note about assetmapper 2026-01-13 05:49:04 +01:00
Hugo Alliaume
70aa6495d4 Update root JS dependencies 2026-01-11 00:13:28 +01:00
Hugo Alliaume
fde719a879 [Translator] Add keys_patterns configuration option to filter dumped translations by key patterns 2025-12-26 18:37:51 +01:00
Hugo Alliaume
9472902436 [Translator] Refactor TranslationsDumper options from __constructor and setters, to dump method 2025-12-20 08:43:53 +01:00
Hugo Alliaume
738bc91d5a [Translator] Reword dump_typescript option description 2025-12-06 21:21:02 +09:00
Hugo Alliaume
4915996a67 [Translator] Add option ux_translator.dump_typescript to enable/disable TypeScript types generation 2025-12-06 04:52:14 +09:00
Hugo Alliaume
ad5d349143 [Translator] Early exit parameters extraction from Intl messages, if no { is found 2025-12-06 03:24:33 +09:00
Hugo Alliaume
5a161e5daa [Translator] Refactor API to use string-based translation keys instead of generated constants 2025-12-03 23:35:13 +01:00
Hugo Alliaume
2ff4d93cf4 Git-ignore config/reference.php 2025-12-02 08:12:06 +01:00
Hugo Alliaume
237ff2f8ad Update versions to 2.31.0 2025-10-27 23:21:26 +01:00
Hugo Alliaume
b4b323fdc8 Add --json flag to composer config command 2025-10-16 09:24:06 +02:00
Thibault G
15b1f8fac8 [Docs] Fix composer config to avoid modifying package.json automatically 2025-09-21 21:04:19 +02:00
Hugo Alliaume
455bd23aa3 Refactor "test_package.sh" to its original purpose, add multiples checks for packages definition 2025-09-20 13:50:04 +02:00
Raphaël Geffroy
8c8ce288a4 [Docs] Add doc for E2E steps + minor modifications 2025-09-19 15:06:31 +02:00
Hugo Alliaume
b6089bad21 [Translator] Add E2E tests 2025-09-15 09:24:44 +02:00
Hugo Alliaume
1b81411a62 Configure .gitattributes to ignore Vitest and Playwright config files from export 2025-09-01 22:55:23 +02:00
github-actions[bot]
2ea2b18abe Update versions to 2.30.0 2025-08-27 18:16:44 +00:00
Hugo Alliaume
9616091db2 Fix changelogs 2025-08-27 17:25:48 +02:00
Hugo Alliaume
d79e1f241e Ensure PHP 8.5 compatibility 2025-08-22 16:23:49 +02:00
Hugo Alliaume
c300b9f41e minor #3014 Create E2E app for browsers tests (Kocal)
This PR was merged into the 2.x branch.

Discussion
----------

 Create E2E app for browsers tests

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no <!-- please update src/**/CHANGELOG.md files -->
| Docs?         | no <!-- required for new features -->
| Issues        | Fix #3009 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT

<!--
Replace this notice by a description of your feature/bugfix.
This will help reviewers and should be a good start for the documentation.

Additionally (see https://symfony.com/releases):
 - Always add tests and ensure they pass.
 - For new features, provide some code snippets to help understand usage.
 - Features and deprecations must be submitted against branch main.
 - Update/add documentation as required (we can help!)
 - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry
 - Never break backward compatibility (see https://symfony.com/bc).
-->

This pull request updates the browser testing workflow and related configuration to improve reliability, consistency, and maintainability across UX packages. The main changes include refactoring the browser test workflow to use a matrix strategy for Symfony versions, standardizing dependency installation steps, removing unused dependencies, and renaming the Playwright configuration for easier reuse.

**Workflow and CI improvements:**

* Refactored `.github/workflows/browser-tests.yml` to use a matrix strategy for Symfony versions, added concurrency controls to cancel in-progress runs, and split setup steps for JS and PHP dependencies, Docker containers, and E2E app configuration. Artifact uploads now only occur on browser test failures. [[1]](diffhunk://#diff-255cac5fcd7ae015d5bc1ccf14bfa2fff33bcabb653402be014e6668db1036ceR23-R38) [[2]](diffhunk://#diff-255cac5fcd7ae015d5bc1ccf14bfa2fff33bcabb653402be014e6668db1036ceL36-R116)
* Standardized JS dependency installation across workflows by replacing direct `pnpm install` commands with named steps (`Install root JS dependencies`) in code quality, unit test, dist files, and release workflows. [[1]](diffhunk://#diff-4a2765c2cfcbd3804a66aab805cb92ddda74de1730923cc5bf53671d0beccf06R27-R36) [[2]](diffhunk://#diff-b117ce55777f198ed74d5eb1cd6319c0b63837e2e9eed5c44b2477658e12248fR24) [[3]](diffhunk://#diff-b117ce55777f198ed74d5eb1cd6319c0b63837e2e9eed5c44b2477658e12248fL32-R38) [[4]](diffhunk://#diff-8e3deeaeb0bdfc6967ff8173f1d99e5001fe75dc497cbfb85fe64ceaade5e399L33-R34) [[5]](diffhunk://#diff-6e608e02c595d53ab6b70822a2bf19abcfc6ddcc976c2f536ad5bfca20f0443fR148) [[6]](diffhunk://#diff-6e608e02c595d53ab6b70822a2bf19abcfc6ddcc976c2f536ad5bfca20f0443fL157-R161)

**Testing and configuration changes:**

* Updated `package.json` to run browser tests in all workspaces concurrently, removed the unused `webdriverio` dependency, and improved the `test:browser` script for workspace aggregation. [[1]](diffhunk://#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519L13-R13) [[2]](diffhunk://#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519L28-R28)
* Renamed `playwright.config.ts` to `playwright.config.base.ts`, added documentation for usage in UX packages, and improved test matching patterns and output directory configuration. [[1]](diffhunk://#diff-8f3b25b652873317fa4aa36b920f753b44dc82f5c1f0d2ff5e6b1781ef1dc90fL1-R30) [[2]](diffhunk://#diff-8f3b25b652873317fa4aa36b920f753b44dc82f5c1f0d2ff5e6b1781ef1dc90fL30-L36)

**Dependency management:**

* Removed `webdriverio` from the lockfile and marked many transitive dependencies as optional in `pnpm-lock.yaml`, reducing the install footprint and improving clarity for unused packages. [[1]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL54-L56) [[2]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR3991) [[3]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4242) [[4]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4260-R4260) [[5]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4270-R4276) [[6]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4429) [[7]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4438-R4451) [[8]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4472-R4480) [[9]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4530) [[10]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4541) [[11]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4543-R4556) [[12]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4578-R4597) [[13]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4597-R4613) [[14]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4622) [[15]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4670) [[16]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4685) [[17]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4704-R4724) [[18]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4733) [[19]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4725-R4756) [[20]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4779-R4793) [[21]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4778-R4807) [[22]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4791-R4828)

These changes make the browser testing workflow more robust and maintainable, ensure consistent dependency installation, and clean up unused or optional packages to streamline CI runs.

Commits
-------

dd1c13aff81 Create E2E app & run it in CI
2025-08-19 20:34:58 +02:00
Hugo Alliaume
459b69936c Create E2E app & run it in CI 2025-08-19 20:30:58 +02:00
github-actions[bot]
f79b5ea73b Update versions to 2.29.2 2025-08-19 12:08:45 +00:00
Hugo Alliaume
7b490265d9 Use Playwright to run E2E tests 2025-08-18 22:25:45 +02:00
Hugo Alliaume
5d15a6c4ee Rename back vitest.config.unit.mjs to vitest.config.mjs 2025-08-18 11:13:24 +02:00
Hugo Alliaume
432487d0e2 Configure Vitest for unit and browser tests (use @puppeteer/browsers and webdriverio) 2025-08-18 08:22:05 +02:00
Hugo Alliaume
5654f0d88d Run latest PHP-CS-Fixer with improved configuration 2025-08-14 22:58:41 +02:00
github-actions[bot]
b34798feab Update versions to 2.29.1 2025-08-08 12:45:34 +00:00
github-actions[bot]
36d90e2035 Update versions to 2.29.0 2025-08-08 11:38:41 +00:00
Hugo Alliaume
3871e849be Add support for Symfony 8 2025-08-06 00:04:40 +02:00
github-actions[bot]
511f6f98ff Update versions to 2.28.2 2025-07-30 12:24:53 +00:00
40 changed files with 1992 additions and 1332 deletions

2
.gitattributes vendored
View File

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

View File

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

View File

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

@@ -1,4 +1,5 @@
/assets/node_modules/
/config/reference.php
/vendor/
/composer.lock
/phpunit.xml

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../playwright.config.base';
export default baseConfig;

View File

@@ -7,168 +7,176 @@
* file that was distributed with this source code.
*/
export type DomainType = string;
export type LocaleType = string;
export type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
export type NoParametersType = Record<string, never>;
export type ParametersType = Record<string, string | number | Date> | NoParametersType;
export type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
export type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
export type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
export type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType>
? Translations[D] extends { parameters: infer Parameters }
? Parameters
: never
: never;
export interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
id: string;
translations: {
[domain in DomainType]: {
[locale in Locale]: string;
};
};
}
import { format } from './formatters/formatter';
import { formatIntl } from './formatters/intl-formatter';
import type { DomainsOf, LocaleOf, LocaleType, MessageId, Messages, ParametersOf, RemoveIntlIcuSuffix } from './types';
let _locale: LocaleType | null = null;
let _localeFallbacks: Record<LocaleType, LocaleType> = {};
let _throwWhenNotFound = false;
export 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
View 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>>;

View 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 €');
});

View File

@@ -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');
});
});
});

View File

@@ -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>]>([

View File

@@ -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', () => {

View 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');
});
});
});

View File

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

@@ -0,0 +1,3 @@
{
"extends": "../../../tsconfig.package.json"
}

4
assets/vitest.config.mjs Normal file
View File

@@ -0,0 +1,4 @@
import { mergeConfig } from 'vitest/config';
import configShared from '../../../vitest.config.base.mjs';
export default mergeConfig(configShared, {});

View File

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

View File

@@ -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'),

View File

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

View File

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

View File

@@ -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()
;

View File

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

View File

@@ -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;
}
/**

View File

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

View File

@@ -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']
)
);

View File

@@ -17,8 +17,6 @@ use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtra
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
use function Symfony\Component\String\s;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
@@ -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;
}
}

View File

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

View File

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

View File

@@ -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' => [
[

View File

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

View File

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

View File

@@ -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'],

View File

@@ -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);
}
/**

View File

@@ -20,6 +20,7 @@ require __DIR__.'/../vendor/autoload.php';
$kernel = new FrameworkAppKernel('test', true);
$application = new Application($kernel);
$application->setAutoExit(false);
// Trigger Symfony Translator and UX Translator cache warmers
$application->run(new StringInput('cache:clear'));
$application->run(new StringInput('cache:clear -vv'));