From f5413d6f18557f66420179ba6c1d5c706bcd2b68 Mon Sep 17 00:00:00 2001 From: Kevin Hamilton Date: Thu, 16 May 2024 10:18:40 +0100 Subject: [PATCH] Initial implementation of placeholders within translated text --- src/ConfigProvider.php | 7 +- src/Translator/LoaderPluginManager.php | 10 +-- .../Placeholder/HandlebarPlaceholder.php | 23 ++++++ src/Translator/Placeholder/IcuPlaceholder.php | 22 ++++++ .../Placeholder/PlaceholderInterface.php | 13 +++ .../Placeholder/PrintfPlaceholder.php | 34 ++++++++ .../Placeholder/SegmentPlaceholder.php | 64 +++++++++++++++ src/Translator/PlaceholderPluginManager.php | 70 ++++++++++++++++ .../PlaceholderPluginManagerFactory.php | 79 +++++++++++++++++++ src/Translator/Translator.php | 28 +++++-- src/Translator/TranslatorServiceFactory.php | 59 +++++++++----- .../TranslatorServiceFactoryTest.php | 39 +++++++-- 12 files changed, 407 insertions(+), 41 deletions(-) create mode 100644 src/Translator/Placeholder/HandlebarPlaceholder.php create mode 100644 src/Translator/Placeholder/IcuPlaceholder.php create mode 100644 src/Translator/Placeholder/PlaceholderInterface.php create mode 100644 src/Translator/Placeholder/PrintfPlaceholder.php create mode 100644 src/Translator/Placeholder/SegmentPlaceholder.php create mode 100644 src/Translator/PlaceholderPluginManager.php create mode 100644 src/Translator/PlaceholderPluginManagerFactory.php diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index c42d5d39..1c20f0ef 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -52,9 +52,10 @@ public function getDependencyConfig() Geography\CountryCodeListInterface::class => Geography\DefaultCountryCodeList::class, ], 'factories' => [ - Translator\TranslatorInterface::class => Translator\TranslatorServiceFactory::class, - Translator\LoaderPluginManager::class => Translator\LoaderPluginManagerFactory::class, - Geography\DefaultCountryCodeList::class => [Geography\DefaultCountryCodeList::class, 'create'], + Translator\TranslatorInterface::class => Translator\TranslatorServiceFactory::class, + Translator\LoaderPluginManager::class => Translator\LoaderPluginManagerFactory::class, + Translator\PlaceholderPluginManager::class => Translator\PlaceholderPluginManagerFactory::class, + Geography\DefaultCountryCodeList::class => [Geography\DefaultCountryCodeList::class, 'create'], ], ]; } diff --git a/src/Translator/LoaderPluginManager.php b/src/Translator/LoaderPluginManager.php index f7a15663..f81c8611 100644 --- a/src/Translator/LoaderPluginManager.php +++ b/src/Translator/LoaderPluginManager.php @@ -98,21 +98,21 @@ class LoaderPluginManager extends AbstractPluginManager * Checks that the filter loaded is an instance of * Loader\FileLoaderInterface or Loader\RemoteLoaderInterface. * - * @param mixed $plugin + * @param mixed $instance * @return void * @throws Exception\RuntimeException If invalid. - * @psalm-assert InstanceType $plugin + * @psalm-assert InstanceType $instance */ - public function validate($plugin) + public function validate($instance) { - if ($plugin instanceof FileLoaderInterface || $plugin instanceof RemoteLoaderInterface) { + if ($instance instanceof FileLoaderInterface || $instance instanceof RemoteLoaderInterface) { // we're okay return; } throw new InvalidServiceException(sprintf( 'Plugin of type %s is invalid; must implement %s or %s', - is_object($plugin) ? $plugin::class : gettype($plugin), + is_object($instance) ? $instance::class : gettype($instance), FileLoaderInterface::class, RemoteLoaderInterface::class )); diff --git a/src/Translator/Placeholder/HandlebarPlaceholder.php b/src/Translator/Placeholder/HandlebarPlaceholder.php new file mode 100644 index 00000000..f0e598da --- /dev/null +++ b/src/Translator/Placeholder/HandlebarPlaceholder.php @@ -0,0 +1,23 @@ + $placeholders + */ + public function compile(string $locale, string $message, iterable $placeholders = []): string + { + $compiled = $message; + foreach ($placeholders as $key => $value) { + $compiled = str_replace("{{{$key}}}", $value, $compiled); + } + + return $compiled; + } +} diff --git a/src/Translator/Placeholder/IcuPlaceholder.php b/src/Translator/Placeholder/IcuPlaceholder.php new file mode 100644 index 00000000..d0f6609b --- /dev/null +++ b/src/Translator/Placeholder/IcuPlaceholder.php @@ -0,0 +1,22 @@ + $placeholders + */ + public function compile(string $locale, string $message, iterable $placeholders = []): string; +} diff --git a/src/Translator/Placeholder/PrintfPlaceholder.php b/src/Translator/Placeholder/PrintfPlaceholder.php new file mode 100644 index 00000000..de913297 --- /dev/null +++ b/src/Translator/Placeholder/PrintfPlaceholder.php @@ -0,0 +1,34 @@ + $placeholders + */ + public function compile(string $locale, string $message, iterable $placeholders = []): string + { + if ($placeholders instanceof Traversable) { + $placeholders = iterator_to_array($placeholders); + } + + /** @var string|false $compiled */ + $compiled = call_user_func_array('vsprintf', [$message, $placeholders]); + if ($compiled === false) { + throw new ParseException( + 'Error occurred while processing sprintf placeholders for message "' . $message . '"' + ); + } + + return $compiled; + } +} diff --git a/src/Translator/Placeholder/SegmentPlaceholder.php b/src/Translator/Placeholder/SegmentPlaceholder.php new file mode 100644 index 00000000..cc96cfe8 --- /dev/null +++ b/src/Translator/Placeholder/SegmentPlaceholder.php @@ -0,0 +1,64 @@ + strlen((string) $b); + }); + + $compiled = $message; + foreach ($placeholders as $key => $value) { + $key = (string) $key; + $compiled = str_replace([':' . $key, ':' . strtoupper($key), ':' . ucfirst($key)], [ + $value, + strtoupper($value), + ucfirst($value), + ], $compiled); + } + } catch (Throwable $e) { + throw new ParseException( + 'An error occurred while replacing placeholders in the message', + 0, + $e + ); + } + + return $compiled; + } +} diff --git a/src/Translator/PlaceholderPluginManager.php b/src/Translator/PlaceholderPluginManager.php new file mode 100644 index 00000000..be6a6dd4 --- /dev/null +++ b/src/Translator/PlaceholderPluginManager.php @@ -0,0 +1,70 @@ + + * @method Placeholder\PlaceholderInterface get(string $name) + */ +class PlaceholderPluginManager extends AbstractPluginManager +{ + /** @inheritDoc */ + protected $aliases = [ + 'colon' => Placeholder\SegmentPlaceholder::class, + 'laravel' => Placeholder\SegmentPlaceholder::class, + 'handlebar' => Placeholder\HandlebarPlaceholder::class, + 'handlebars' => Placeholder\HandlebarPlaceholder::class, + 'icu' => Placeholder\IcuPlaceholder::class, + 'vsprintf' => Placeholder\PrintfPlaceholder::class, + 'sprintf' => Placeholder\PrintfPlaceholder::class, + 'printf' => Placeholder\PrintfPlaceholder::class, + ]; + + /** @inheritDoc */ + protected $factories = [ + Placeholder\SegmentPlaceholder::class => InvokableFactory::class, + Placeholder\HandlebarPlaceholder::class => InvokableFactory::class, + Placeholder\IcuPlaceholder::class => InvokableFactory::class, + Placeholder\PrintfPlaceholder::class => InvokableFactory::class, + ]; + + /** + * Validate the plugin. + * + * Checks that the filter loaded is an instance of + * Loader\FileLoaderInterface or Loader\RemoteLoaderInterface. + * + * @throws Exception\RuntimeException If invalid. + * @psalm-assert RemoteLoaderInterface $instance + */ + public function validate(mixed $instance): void + { + if ($instance instanceof Placeholder\PlaceholderInterface) { + // we're okay + return; + } + + throw new InvalidServiceException(sprintf( + 'Plugin of type %s is invalid; must implement %s', + is_object($instance) ? $instance::class : gettype($instance), + Placeholder\PlaceholderInterface::class + )); + } +} diff --git a/src/Translator/PlaceholderPluginManagerFactory.php b/src/Translator/PlaceholderPluginManagerFactory.php new file mode 100644 index 00000000..bb505c96 --- /dev/null +++ b/src/Translator/PlaceholderPluginManagerFactory.php @@ -0,0 +1,79 @@ +|null $options + * @psalm-param ServiceManagerConfiguration|null $options + * @return LoaderPluginManager + */ + public function __invoke(ContainerInterface $container, $name, ?array $options = null) + { + $options = $options ?? []; + $pluginManager = new PlaceholderPluginManager($container, $options); + + // If this is in a laminas-mvc application, the ServiceListener will inject + // merged configuration during bootstrap. + if ($container->has('ServiceListener')) { + return $pluginManager; + } + + // If we do not have a config service, nothing more to do + if (! $container->has('config')) { + return $pluginManager; + } + + $config = $container->get('config'); + + // If we do not have translator_plugins configuration, nothing more to do + if (! isset($config['translator_placeholders']) || ! is_array($config['translator_placeholders'])) { + return $pluginManager; + } + + // Wire service configuration for translator_plugins + (new Config($config['translator_placeholders']))->configureServiceManager($pluginManager); + + return $pluginManager; + } + + /** + * laminas-servicemanager v2 factory to return LoaderPluginManager + * + * @deprecated Since 2.16.0 - This component is no longer compatible with Service Manager v2. + * This method will be removed in version 3.0 + * + * @return LoaderPluginManager + */ + public function createService(ServiceLocatorInterface $container) + { + return $this($container, 'TranslatorPluginManager', $this->creationOptions); + } + + /** + * v2 support for instance creation options. + * + * @deprecated Since 2.16.0 - This component is no longer compatible with Service Manager v2. + * This method will be removed in version 3.0 + * + * @param array $options + * @return void + */ + public function setCreationOptions(array $options) + { + $this->creationOptions = $options; + } +} diff --git a/src/Translator/Translator.php b/src/Translator/Translator.php index 597d2395..c54cc7fb 100644 --- a/src/Translator/Translator.php +++ b/src/Translator/Translator.php @@ -10,6 +10,7 @@ use Laminas\I18n\Exception; use Laminas\I18n\Translator\Loader\FileLoaderInterface; use Laminas\I18n\Translator\Loader\RemoteLoaderInterface; +use Laminas\I18n\Translator\Placeholder\PlaceholderInterface; use Laminas\ServiceManager\ServiceManager; use Laminas\Stdlib\ArrayUtils; use Locale; @@ -109,6 +110,8 @@ class Translator implements TranslatorInterface */ protected $eventsEnabled = false; + protected ?PlaceholderInterface $placeholder = null; + /** * Instantiate a translator * @@ -338,14 +341,14 @@ public function getPluginManager() /** * Translate a message. * - * @param string $message - * @param string $textDomain - * @param string|null $locale + * @param string $message + * @param string|string[] $textDomain + * @param string|null $locale * @return string */ public function translate($message, $textDomain = 'default', $locale = null) { - $locale = $locale ?? $this->getLocale(); + $locale ??= $this->getLocale(); $translation = $this->getTranslatedMessage($message, $locale, $textDomain); if ($translation !== null && $translation !== '') { @@ -418,9 +421,9 @@ public function translatePlural( * Get a translated message. * * @triggers getTranslatedMessage.missing-translation - * @param string $message - * @param string $locale - * @param string $textDomain + * @param string $message + * @param string $locale + * @param string|string[] $textDomain or placeholders * @return string|null */ protected function getTranslatedMessage( @@ -432,6 +435,12 @@ protected function getTranslatedMessage( return ''; } + $placeholders = []; + if (is_array($textDomain)) { + $placeholders = $textDomain; + $textDomain = $placeholders['_textDomain'] ?? 'default'; + } + if (! isset($this->messages[$textDomain][$locale])) { $this->loadMessages($textDomain, $locale); } @@ -823,4 +832,9 @@ public function disableEventManager() $this->eventsEnabled = false; return $this; } + + public function setPlaceholder(PlaceholderInterface $placeholder) + { + $this->placeholder = $placeholder; + } } diff --git a/src/Translator/TranslatorServiceFactory.php b/src/Translator/TranslatorServiceFactory.php index d7b61f8a..3830bb1e 100644 --- a/src/Translator/TranslatorServiceFactory.php +++ b/src/Translator/TranslatorServiceFactory.php @@ -2,9 +2,17 @@ namespace Laminas\I18n\Translator; -use Laminas\ServiceManager\FactoryInterface; -use Laminas\ServiceManager\ServiceLocatorInterface; +use Laminas\ServiceManager\Exception\InvalidServiceException; +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; + +use function gettype; +use function is_object; +use function is_string; +use function sprintf; /** * Translator. @@ -15,33 +23,44 @@ class TranslatorServiceFactory implements FactoryInterface * Create a Translator instance. * * @param string $requestedName - * @param null|array $options - * @return Translator + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ - public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null) + public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): Translator { // Configure the translator + /** @var array $config */ $config = $container->get('config'); $trConfig = $config['translator'] ?? []; $translator = Translator::factory($trConfig); if ($container->has('TranslatorPluginManager')) { $translator->setPluginManager($container->get('TranslatorPluginManager')); } - return $translator; - } - /** - * laminas-servicemanager v2 factory for creating Translator instance. - * - * @deprecated Since 2.16.0 - This component is no longer compatible with Service Manager v2. - * This method will be removed in version 3.0 - * - * Proxies to `__invoke()`. - * - * @return Translator - */ - public function createService(ServiceLocatorInterface $serviceLocator) - { - return $this($serviceLocator, Translator::class); + /** @var PlaceholderPluginManager $placeholderManager */ + $placeholderManager = $container->get(PlaceholderPluginManager::class); + /** @var mixed $placeholderName */ + $placeholderName = $trConfig['placeholder_format'] ?? 'handlebars'; + if ($placeholderName instanceof Placeholder\PlaceholderInterface) { + $placeholder = $placeholderName; + } elseif (is_string($placeholderName)) { + if (! $placeholderManager->has($placeholderName)) { + throw new ServiceNotCreatedException( + sprintf('Could not find a placeholder format with the name "%s"', $placeholderName) + ); + } + + $placeholder = $placeholderManager->get($placeholderName); + } else { + throw new InvalidServiceException(sprintf( + '\'placeholder_format\' of type %s is invalid; must be a string or object that implements %s', + is_object($placeholderName) ? $placeholderName::class : gettype($placeholderName), + Placeholder\PlaceholderInterface::class + )); + } + + $translator->setPlaceholder($placeholder); + + return $translator; } } diff --git a/test/Translator/TranslatorServiceFactoryTest.php b/test/Translator/TranslatorServiceFactoryTest.php index b9ea4df3..dd165953 100644 --- a/test/Translator/TranslatorServiceFactoryTest.php +++ b/test/Translator/TranslatorServiceFactoryTest.php @@ -5,6 +5,8 @@ namespace LaminasTest\I18n\Translator; use Laminas\I18n\Translator\LoaderPluginManager; +use Laminas\I18n\Translator\Placeholder\HandlebarPlaceholder; +use Laminas\I18n\Translator\PlaceholderPluginManager; use Laminas\I18n\Translator\Translator; use Laminas\I18n\Translator\TranslatorServiceFactory; use LaminasTest\I18n\TestCase; @@ -14,7 +16,8 @@ class TranslatorServiceFactoryTest extends TestCase { public function testCreateServiceWithNoTranslatorKeyDefined(): void { - $pluginManagerMock = $this->createMock(LoaderPluginManager::class); + $pluginManagerMock = $this->createMock(LoaderPluginManager::class); + $placeholderManagerMock = $this->createMock(PlaceholderPluginManager::class); $serviceLocator = $this->createMock(ContainerInterface::class); $serviceLocator->expects(self::once()) @@ -22,10 +25,21 @@ public function testCreateServiceWithNoTranslatorKeyDefined(): void ->with('TranslatorPluginManager') ->willReturn(true); - $serviceLocator->expects(self::exactly(2)) + $placeholderManagerMock->expects(self::once()) + ->method('has') + ->with('handlebars') + ->willReturn(true); + + $placeholderManagerMock->expects(self::once()) + ->method('get') + ->with('handlebars') + ->willReturn(new HandlebarPlaceholder()); + + $serviceLocator->expects(self::exactly(3)) ->method('get') ->willReturnMap([ ['TranslatorPluginManager', $pluginManagerMock], + [PlaceholderPluginManager::class, $placeholderManagerMock], ['config', []], ]); @@ -43,10 +57,23 @@ public function testCreateServiceWithNoTranslatorPluginManagerDefined(): void ->with('TranslatorPluginManager') ->willReturn(false); - $serviceLocator->expects(self::once()) - ->method('get') - ->with('config') - ->willReturn([]); + $placeholderManagerMock = $this->createMock(PlaceholderPluginManager::class); + $serviceLocator->expects(self::exactly(2)) + ->method('get') + ->willReturnMap([ + [PlaceholderPluginManager::class, $placeholderManagerMock], + ['config', []], + ]); + + $placeholderManagerMock->expects(self::once()) + ->method('has') + ->with('handlebars') + ->willReturn(true); + + $placeholderManagerMock->expects(self::once()) + ->method('get') + ->with('handlebars') + ->willReturn(new HandlebarPlaceholder()); $factory = new TranslatorServiceFactory(); $translator = $factory($serviceLocator, Translator::class);