[Translator] Add keys_patterns configuration option to filter dumped translations by key patterns

This commit is contained in:
Hugo Alliaume
2025-12-21 07:37:54 +01:00
parent 9472902436
commit fde719a879
9 changed files with 251 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class TranslationsCacheWarmer implements CacheWarmerInterface
/**
* @param list<string> $includedDomains
* @param list<string> $excludedDomains
* @param list<string> $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

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ class TranslationsDumper
* @param list<MessageCatalogueInterface> $catalogues
* @param list<Domain> $includedDomains
* @param list<Domain> $excludedDomains
* @param list<string> $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<MessageId, array<Domain, array<Locale, string>>>
*/
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<string> $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;
}
}

View File

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

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