mirror of
https://github.com/symfony/ux-translator.git
synced 2026-03-24 00:12:19 +01:00
[Translator] Add keys_patterns configuration option to filter dumped translations by key patterns
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user