From fde719a87903d9bc6fe60abf7581c1143532c918 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sun, 21 Dec 2025 07:37:54 +0100 Subject: [PATCH] [Translator] Add `keys_patterns` configuration option to filter dumped translations by key patterns --- CHANGELOG.md | 6 +- config/services.php | 1 + doc/index.rst | 25 ++- src/CacheWarmer/TranslationsCacheWarmer.php | 3 + src/DependencyInjection/Configuration.php | 8 + .../UxTranslatorExtension.php | 1 + src/TranslationsDumper.php | 69 ++++++++- .../TranslationsCacheWarmerTest.php | 6 +- tests/TranslationsDumperTest.php | 145 ++++++++++++++++++ 9 files changed, 251 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec296f..ab0daed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,8 @@ **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. - A future feature will allow filtering dumped translations by pattern for those who need it, - further reducing bundle size. + 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 @@ -59,6 +59,8 @@ 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 diff --git a/config/services.php b/config/services.php index 0aedc46..58f1aed 100644 --- a/config/services.php +++ b/config/services.php @@ -30,6 +30,7 @@ return static function (ContainerConfigurator $container): void { abstract_arg('dump_typescript'), abstract_arg('included_domains'), abstract_arg('excluded_domains'), + abstract_arg('keys_patterns'), ]) ->tag('kernel.cache_warmer') diff --git a/doc/index.rst b/doc/index.rst index 35b4643..9514d2d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -98,13 +98,24 @@ 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -201,8 +212,8 @@ All your translations (extracted from the configured domains) are included in th which means they will be included in your final JavaScript bundle). However, modern build tools, caching strategies, and compression techniques (Brotli, gzip) -make this negligible in 2025. Additionally, a future feature will allow filtering dumped -translations by pattern for those who need to further reduce bundle size. +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 ------------------------------ diff --git a/src/CacheWarmer/TranslationsCacheWarmer.php b/src/CacheWarmer/TranslationsCacheWarmer.php index 69f2e68..18f12a3 100644 --- a/src/CacheWarmer/TranslationsCacheWarmer.php +++ b/src/CacheWarmer/TranslationsCacheWarmer.php @@ -27,6 +27,7 @@ class TranslationsCacheWarmer implements CacheWarmerInterface /** * @param list $includedDomains * @param list $excludedDomains + * @param list $keysPatterns */ public function __construct( private TranslatorBagInterface $translatorBag, @@ -35,6 +36,7 @@ class TranslationsCacheWarmer implements CacheWarmerInterface private bool $dumpTypeScript, private array $includedDomains, private array $excludedDomains, + private array $keysPatterns, ) { } @@ -51,6 +53,7 @@ class TranslationsCacheWarmer implements CacheWarmerInterface $this->dumpTypeScript, $this->includedDomains, $this->excludedDomains, + $this->keysPatterns, ); // No need to preload anything diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 7833740..b1a756f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -89,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(fn ($v) => [$v]) + ->end() + ->end() ->end() ; diff --git a/src/DependencyInjection/UxTranslatorExtension.php b/src/DependencyInjection/UxTranslatorExtension.php index 6286980..c810cb5 100644 --- a/src/DependencyInjection/UxTranslatorExtension.php +++ b/src/DependencyInjection/UxTranslatorExtension.php @@ -51,6 +51,7 @@ class UxTranslatorExtension extends Extension implements PrependExtensionInterfa $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 diff --git a/src/TranslationsDumper.php b/src/TranslationsDumper.php index 8d811ce..e2da434 100644 --- a/src/TranslationsDumper.php +++ b/src/TranslationsDumper.php @@ -42,6 +42,7 @@ class TranslationsDumper * @param list $catalogues * @param list $includedDomains * @param list $excludedDomains + * @param list $keysPatterns */ public function dump( array $catalogues, @@ -49,11 +50,25 @@ class TranslationsDumper 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.'); } + $includedKeysPatterns = []; + $excludedKeysPatterns = []; + foreach ($keysPatterns as $pattern) { + if (str_starts_with($pattern, '!')) { + $excludedKeysPatterns[] = substr($pattern, 1); + } else { + $includedKeysPatterns[] = $pattern; + } + } + + $includeKeysRegex = $this->buildPatternRegex($includedKeysPatterns); + $excludeKeysRegex = $this->buildPatternRegex($excludedKeysPatterns); + $this->filesystem->mkdir($dumpDir); $this->filesystem->remove($fileIndexJs = $dumpDir.'/index.js'); $this->filesystem->remove($fileIndexDts = $dumpDir.'/index.d.ts'); @@ -84,7 +99,7 @@ class TranslationsDumper ); } - foreach ($this->getTranslations($catalogues, $excludedDomains, $includedDomains) as $translationId => $translationsByDomainAndLocale) { + foreach ($this->getTranslations($catalogues, $excludedDomains, $includedDomains, $includeKeysRegex, $excludeKeysRegex) as $translationId => $translationsByDomainAndLocale) { $translationId = str_replace('"', '\\"', $translationId); $this->filesystem->appendToFile($fileIndexJs, \sprintf( ' "%s": %s,%s', @@ -117,7 +132,7 @@ class TranslationsDumper * * @return array>> */ - private function getTranslations(array $catalogues, array $excludedDomains, array $includedDomains): array + private function getTranslations(array $catalogues, array $excludedDomains, array $includedDomains, ?string $includeKeysRegex, ?string $excludeKeysRegex): array { $translations = []; @@ -131,6 +146,14 @@ class TranslationsDumper 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; @@ -195,4 +218,46 @@ class TranslationsDumper return $localesFallbacks; } + + /** + * @param list $patterns + */ + private function buildPatternRegex(array $patterns): ?string + { + if ([] === $patterns) { + return null; + } + + // Filter empty patterns and deduplicate + $patterns = array_reduce($patterns, function (array $carry, string $pattern) { + $trimmed = trim($pattern); + if ('' !== $trimmed && !\in_array($trimmed, $carry, true)) { + $carry[] = $trimmed; + } + + 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; + } } diff --git a/tests/CacheWarmer/TranslationsCacheWarmerTest.php b/tests/CacheWarmer/TranslationsCacheWarmerTest.php index fdca03d..5aa27f0 100644 --- a/tests/CacheWarmer/TranslationsCacheWarmerTest.php +++ b/tests/CacheWarmer/TranslationsCacheWarmerTest.php @@ -46,12 +46,13 @@ final class TranslationsCacheWarmerTest extends TestCase $dumpTypeScript = true; $includedDomains = []; $excludedDomains = []; + $keysPatterns = []; $translationsDumperMock = $this->createMock(TranslationsDumper::class); $translationsDumperMock ->expects($this->once()) ->method('dump') - ->with($translatorBag->getCatalogues(), $dumpDir, $dumpTypeScript, $includedDomains, $excludedDomains); + ->with($translatorBag->getCatalogues(), $dumpDir, $dumpTypeScript, $includedDomains, $excludedDomains, $keysPatterns); $translationsCacheWarmer = new TranslationsCacheWarmer( $translatorBag, @@ -59,7 +60,8 @@ final class TranslationsCacheWarmerTest extends TestCase $dumpDir, $dumpTypeScript, $includedDomains, - $excludedDomains + $excludedDomains, + $keysPatterns, ); $translationsCacheWarmer->warmUp(self::$cacheDir); diff --git a/tests/TranslationsDumperTest.php b/tests/TranslationsDumperTest.php index 019a1d6..d396f37 100644 --- a/tests/TranslationsDumperTest.php +++ b/tests/TranslationsDumperTest.php @@ -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; @@ -206,6 +207,150 @@ class TranslationsDumperTest extends TestCase ); } + public static function keysPatternProvider(): iterable + { + yield 'included patterns' => [ + ['symfony.*', 'notification.*'], + 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.*'], + 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*'], + 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.*'], + 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' => [ + [], + 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.*', ''], + 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' => [ + ['!'], + 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.*'], + 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.*'], + 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); + } + /** * @return list */