diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php index 906f160b4f11f..3fdc572972605 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationDebugCommand.php @@ -370,7 +370,7 @@ private function loadFallbackCatalogues(string $locale, array $transPaths): arra { $fallbackCatalogues = []; if ($this->translator instanceof Translator || $this->translator instanceof DataCollectorTranslator || $this->translator instanceof LoggingTranslator) { - foreach ($this->translator->getFallbackLocales() as $fallbackLocale) { + foreach ($this->translator->getFallbackLocaleProvider()->getUltimateFallbackLocales() as $fallbackLocale) { if ($fallbackLocale === $locale) { continue; } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index f0b15e6522aff..5deb3a2986887 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1269,7 +1269,14 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $container->setAlias('translator', 'translator.default')->setPublic(true); $container->setAlias('translator.formatter', new Alias($config['formatter'], false)); $translator = $container->findDefinition('translator.default'); - $translator->addMethodCall('setFallbackLocales', [$config['fallbacks'] ?: [$defaultLocale]]); + + if ($container->has('translation.fallback_locale_provider')) { + $container + ->findDefinition('translation.fallback_locale_provider') + ->replaceArgument(0, [$config['fallbacks'] ?: [$defaultLocale]]); + } else { + $translator->addMethodCall('setFallbackLocales', [$config['fallbacks'] ?: [$defaultLocale]]); + } $defaultOptions = $translator->getArgument(4); $defaultOptions['cache_dir'] = $config['cache_dir']; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index 7758078f277cf..6903e4ca4f25b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -27,6 +27,8 @@ use Symfony\Component\Translation\Extractor\ChainExtractor; use Symfony\Component\Translation\Extractor\ExtractorInterface; use Symfony\Component\Translation\Extractor\PhpExtractor; +use Symfony\Component\Translation\FallbackLocaleProvider; +use Symfony\Component\Translation\FallbackLocaleProviderInterface; use Symfony\Component\Translation\Formatter\MessageFormatter; use Symfony\Component\Translation\Loader\CsvFileLoader; use Symfony\Component\Translation\Loader\IcuDatFileLoader; @@ -59,6 +61,7 @@ 'debug' => param('kernel.debug'), ], abstract_arg('enabled locales'), + service('translation.fallback_locale_provider')->ignoreOnInvalid(), ]) ->call('setConfigCacheFactory', [service('config_cache_factory')]) ->tag('kernel.locale_aware') @@ -163,4 +166,10 @@ ->tag('container.service_subscriber', ['id' => 'translator']) ->tag('kernel.cache_warmer') ; + + if (class_exists(FallbackLocaleProvider::class)) { + $container->services() + ->set('translation.fallback_locale_provider', FallbackLocaleProvider::class) + ->alias(FallbackLocaleProviderInterface::class, 'translation.fallback_locale_provider'); + } }; diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 720298d8e06c2..6b716567f680e 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Parameters implementing `TranslatableInterface` are processed * Add the file extension to the `XliffFileDumper` constructor + * Add `FallbackLocaleProvider` to configure the fallback locale chain in `Translator` 5.4 --- diff --git a/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php b/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php index 4244511e46afa..8793ca045cd11 100644 --- a/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php +++ b/src/Symfony/Component/Translation/DataCollector/TranslationDataCollector.php @@ -51,7 +51,7 @@ public function lateCollect() public function collect(Request $request, Response $response, \Throwable $exception = null) { $this->data['locale'] = $this->translator->getLocale(); - $this->data['fallback_locales'] = $this->translator->getFallbackLocales(); + $this->data['fallback_locales'] = $this->translator->getFallbackLocaleProvider()->getUltimateFallbackLocales(); } /** diff --git a/src/Symfony/Component/Translation/DataCollectorTranslator.php b/src/Symfony/Component/Translation/DataCollectorTranslator.php index cab9874608db1..ab9c0b308a147 100644 --- a/src/Symfony/Component/Translation/DataCollectorTranslator.php +++ b/src/Symfony/Component/Translation/DataCollectorTranslator.php @@ -102,6 +102,8 @@ public function warmUp(string $cacheDir): array */ public function getFallbackLocales(): array { + trigger_deprecation('symfony/translation', '6.2', 'DataCollectorTranslator::getFallbackLocales() is deprecated. Get the FallbackLocaleProvider instead.'); + if ($this->translator instanceof Translator || method_exists($this->translator, 'getFallbackLocales')) { return $this->translator->getFallbackLocales(); } diff --git a/src/Symfony/Component/Translation/FallbackLocaleProvider.php b/src/Symfony/Component/Translation/FallbackLocaleProvider.php new file mode 100644 index 0000000000000..e137008de55a0 --- /dev/null +++ b/src/Symfony/Component/Translation/FallbackLocaleProvider.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; + +/** + * Derives fallback locales based on ICU parent locale information, by shortening locale + * sub tags and ultimately by going through a list of configured fallback locales. + * + * @author Matthias Pigulla + */ +class FallbackLocaleProvider implements FallbackLocaleProviderInterface +{ + /** + * @var string[] + * + * List of fallback locales to add _after_ the ones derived from ICU information. + */ + private array $ultimateFallbackLocales; + + private ?array $parentLocales = null; + + /** + * @param string[] $ultimateFallbackLocales + * + * @throws InvalidArgumentException If a locale contains invalid characters + */ + public function __construct(array $ultimateFallbackLocales = []) + { + foreach ($ultimateFallbackLocales as $locale) { + LocaleValidator::validate($locale); + } + + $this->ultimateFallbackLocales = $ultimateFallbackLocales; + } + + /** + * @return string[] + */ + public function getUltimateFallbackLocales(): array + { + return $this->ultimateFallbackLocales; + } + + /** + * @return string[] + */ + public function computeFallbackLocales(string $locale): array + { + LocaleValidator::validate($locale); + + $this->parentLocales ??= json_decode(file_get_contents(__DIR__.'/Resources/data/parents.json'), true); + + $originLocale = $locale; + $locales = []; + + while ($locale) { + $parent = $this->parentLocales[$locale] ?? null; + + if ($parent) { + $locale = 'root' !== $parent ? $parent : null; + } elseif (\function_exists('locale_parse')) { + $localeSubTags = locale_parse($locale); + $locale = null; + if (1 < \count($localeSubTags)) { + array_pop($localeSubTags); + $locale = locale_compose($localeSubTags) ?: null; + } + } elseif ($i = strrpos($locale, '_') ?: strrpos($locale, '-')) { + $locale = substr($locale, 0, $i); + } else { + $locale = null; + } + + if (null !== $locale) { + $locales[] = $locale; + } + } + + foreach ($this->ultimateFallbackLocales as $fallback) { + if ($fallback === $originLocale) { + continue; + } + + $locales[] = $fallback; + } + + return array_unique($locales); + } +} diff --git a/src/Symfony/Component/Translation/FallbackLocaleProviderInterface.php b/src/Symfony/Component/Translation/FallbackLocaleProviderInterface.php new file mode 100644 index 0000000000000..ae536174f3cf5 --- /dev/null +++ b/src/Symfony/Component/Translation/FallbackLocaleProviderInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; + +/** + * For a given locale, implementations provide the list of alternative locales to + * try when a translation cannot be found. + * + * @author Matthias Pigulla + */ +interface FallbackLocaleProviderInterface +{ + /** + * @internal + * + * @return string[] + */ + public function getUltimateFallbackLocales(): array; + + /** + * For a given locale, this method provides the ordered list of alternative (fallback) locales + * to try. + * + * @return string[] + */ + public function computeFallbackLocales(string $locale): array; +} diff --git a/src/Symfony/Component/Translation/LocaleValidator.php b/src/Symfony/Component/Translation/LocaleValidator.php new file mode 100644 index 0000000000000..6b5004acb6be6 --- /dev/null +++ b/src/Symfony/Component/Translation/LocaleValidator.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; + +/** + * Asserts (syntactical) validity for given locale identifiers. + * + * @author Matthias Pigulla + */ +class LocaleValidator +{ + /** + * Asserts that the locale is valid, throws an Exception if not. + * + * @throws InvalidArgumentException If the locale contains invalid characters + */ + public static function validate(string $locale): void + { + if (!preg_match('/^[a-z0-9@_\\.\\-]*$/i', $locale)) { + throw new InvalidArgumentException(sprintf('Invalid "%s" locale.', $locale)); + } + } +} diff --git a/src/Symfony/Component/Translation/LoggingTranslator.php b/src/Symfony/Component/Translation/LoggingTranslator.php index 8dd8ecf96be29..bfac567c5d488 100644 --- a/src/Symfony/Component/Translation/LoggingTranslator.php +++ b/src/Symfony/Component/Translation/LoggingTranslator.php @@ -91,6 +91,8 @@ public function getCatalogues(): array */ public function getFallbackLocales(): array { + trigger_deprecation('symfony/translation', '6.2', 'LoggingTranslator::getFallbackLocales() is deprecated. Get the FallbackLocaleProvider instead.'); + if ($this->translator instanceof Translator || method_exists($this->translator, 'getFallbackLocales')) { return $this->translator->getFallbackLocales(); } diff --git a/src/Symfony/Component/Translation/Translator.php b/src/Symfony/Component/Translation/Translator.php index d2e5c73f24a70..e4c0e5f799ae5 100644 --- a/src/Symfony/Component/Translation/Translator.php +++ b/src/Symfony/Component/Translation/Translator.php @@ -40,11 +40,6 @@ class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleA private string $locale; - /** - * @var string[] - */ - private array $fallbackLocales = []; - /** * @var LoaderInterface[] */ @@ -62,14 +57,14 @@ class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleA private ?ConfigCacheFactoryInterface $configCacheFactory; - private array $parentLocales; - private bool $hasIntlFormatter; + private FallbackLocaleProviderInterface $fallbackLocaleProvider; + /** * @throws InvalidArgumentException If a locale contains invalid characters */ - public function __construct(string $locale, MessageFormatterInterface $formatter = null, string $cacheDir = null, bool $debug = false, array $cacheVary = []) + public function __construct(string $locale, MessageFormatterInterface $formatter = null, string $cacheDir = null, bool $debug = false, array $cacheVary = [], FallbackLocaleProviderInterface $fallbackLocaleProvider = null) { $this->setLocale($locale); @@ -82,6 +77,7 @@ public function __construct(string $locale, MessageFormatterInterface $formatter $this->debug = $debug; $this->cacheVary = $cacheVary; $this->hasIntlFormatter = $formatter instanceof IntlFormatterInterface; + $this->fallbackLocaleProvider = $fallbackLocaleProvider ?? new FallbackLocaleProvider(); } public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory) @@ -89,6 +85,20 @@ public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFa $this->configCacheFactory = $configCacheFactory; } + public function setFallbackLocaleProvider(FallbackLocaleProviderInterface $fallbackLocaleProvider) + { + $this->fallbackLocaleProvider = $fallbackLocaleProvider; + + // needed as the fallback locales are linked to the already loaded catalogues + $this->catalogues = []; + $this->cacheVary['fallback_locales'] = $fallbackLocaleProvider->getUltimateFallbackLocales(); + } + + public function getFallbackLocaleProvider(): FallbackLocaleProviderInterface + { + return $this->fallbackLocaleProvider; + } + /** * Adds a Loader. * @@ -118,7 +128,7 @@ public function addResource(string $format, mixed $resource, string $locale, str $this->resources[$locale][] = [$format, $resource, $domain]; - if (\in_array($locale, $this->fallbackLocales)) { + if (\in_array($locale, $this->fallbackLocaleProvider->getUltimateFallbackLocales())) { $this->catalogues = []; } else { unset($this->catalogues[$locale]); @@ -151,24 +161,19 @@ public function getLocale(): string */ public function setFallbackLocales(array $locales) { - // needed as the fallback locales are linked to the already loaded catalogues - $this->catalogues = []; + trigger_deprecation('symfony/translation', '6.2', 'Changing the fallback locales on the Translator is deprecated. Provide a new FallbackLocaleProviderInterface instance instead.'); - foreach ($locales as $locale) { - $this->assertValidLocale($locale); - } - - $this->fallbackLocales = $this->cacheVary['fallback_locales'] = $locales; + $this->setFallbackLocaleProvider(new FallbackLocaleProvider($locales)); } /** - * Gets the fallback locales. - * * @internal */ public function getFallbackLocales(): array { - return $this->fallbackLocales; + trigger_deprecation('symfony/translation', '6.2', 'Querying fallback locales on the Translator is deprecated. Get the FallbackLocaleProvider instead and either use it to compute the fallback locale order, or query its ultimate fallback locale list.'); + + return $this->fallbackLocaleProvider->getUltimateFallbackLocales(); } /** @@ -393,43 +398,7 @@ private function loadFallbackCatalogues(string $locale): void protected function computeFallbackLocales(string $locale) { - $this->parentLocales ??= json_decode(file_get_contents(__DIR__.'/Resources/data/parents.json'), true); - - $originLocale = $locale; - $locales = []; - - while ($locale) { - $parent = $this->parentLocales[$locale] ?? null; - - if ($parent) { - $locale = 'root' !== $parent ? $parent : null; - } elseif (\function_exists('locale_parse')) { - $localeSubTags = locale_parse($locale); - $locale = null; - if (1 < \count($localeSubTags)) { - array_pop($localeSubTags); - $locale = locale_compose($localeSubTags) ?: null; - } - } elseif ($i = strrpos($locale, '_') ?: strrpos($locale, '-')) { - $locale = substr($locale, 0, $i); - } else { - $locale = null; - } - - if (null !== $locale) { - $locales[] = $locale; - } - } - - foreach ($this->fallbackLocales as $fallback) { - if ($fallback === $originLocale) { - continue; - } - - $locales[] = $fallback; - } - - return array_unique($locales); + return $this->fallbackLocaleProvider->computeFallbackLocales($locale); } /** @@ -439,9 +408,7 @@ protected function computeFallbackLocales(string $locale) */ protected function assertValidLocale(string $locale) { - if (!preg_match('/^[a-z0-9@_\\.\\-]*$/i', $locale)) { - throw new InvalidArgumentException(sprintf('Invalid "%s" locale.', $locale)); - } + LocaleValidator::validate($locale); } /**