Skip to content

Commit

Permalink
Initial implementation of placeholders within translated text
Browse files Browse the repository at this point in the history
  • Loading branch information
TotalWipeOut committed May 16, 2024
1 parent b3259ac commit f5413d6
Show file tree
Hide file tree
Showing 12 changed files with 407 additions and 41 deletions.
7 changes: 4 additions & 3 deletions src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
];
}
Expand Down
10 changes: 5 additions & 5 deletions src/Translator/LoaderPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
));
Expand Down
23 changes: 23 additions & 0 deletions src/Translator/Placeholder/HandlebarPlaceholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Laminas\I18n\Translator\Placeholder;

use function str_replace;

class HandlebarPlaceholder implements PlaceholderInterface
{
/**
* @param iterable<string, string> $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;
}
}
22 changes: 22 additions & 0 deletions src/Translator/Placeholder/IcuPlaceholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Laminas\I18n\Translator\Placeholder;

use MessageFormatter;
use Traversable;

use function iterator_to_array;

class IcuPlaceholder implements PlaceholderInterface
{
public function compile(string $locale, string $message, iterable $placeholders = []): string
{
if ($placeholders instanceof Traversable) {
$placeholders = iterator_to_array($placeholders);
}

return MessageFormatter::formatMessage($locale, $message, $placeholders);
}
}
13 changes: 13 additions & 0 deletions src/Translator/Placeholder/PlaceholderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Laminas\I18n\Translator\Placeholder;

interface PlaceholderInterface
{
/**
* @param iterable<string|int, string> $placeholders
*/
public function compile(string $locale, string $message, iterable $placeholders = []): string;
}
34 changes: 34 additions & 0 deletions src/Translator/Placeholder/PrintfPlaceholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Laminas\I18n\Translator\Placeholder;

use Laminas\I18n\Exception\ParseException;
use Traversable;

use function call_user_func_array;
use function iterator_to_array;

class PrintfPlaceholder implements PlaceholderInterface
{
/**
* @param iterable<int, string> $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;
}
}
64 changes: 64 additions & 0 deletions src/Translator/Placeholder/SegmentPlaceholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Laminas\I18n\Translator\Placeholder;

use Laminas\I18n\Exception\InvalidArgumentException;
use Laminas\I18n\Exception\ParseException;
use Laminas\Stdlib\ArrayUtils;
use Throwable;
use Traversable;

use function iterator_to_array;
use function str_replace;
use function strlen;
use function strtoupper;
use function ucfirst;
use function uksort;

class SegmentPlaceholder implements PlaceholderInterface
{
public function compile(string $locale, string $message, iterable $placeholders = []): string
{
if ($placeholders instanceof Traversable) {
$placeholders = iterator_to_array($placeholders);
}

if (empty($placeholders)) {
return $message;
}

if (! ArrayUtils::hasStringKeys($placeholders)) {
throw new InvalidArgumentException(
'SegmentPlaceholder expects an associative array of placeholder names and values'
);
}

try {
// Sorting the array by key length to replace placeholders with longer names first
// to avoid replacing placeholders with shorter names that are part of longer names
uksort($placeholders, static function (string|int $a, string|int $b) {
return strlen((string) $a) <=> 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;
}
}
70 changes: 70 additions & 0 deletions src/Translator/PlaceholderPluginManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace Laminas\I18n\Translator;

use Laminas\I18n\Exception;
use Laminas\I18n\Translator\Loader\RemoteLoaderInterface;
use Laminas\ServiceManager\AbstractPluginManager;
use Laminas\ServiceManager\Exception\InvalidServiceException;
use Laminas\ServiceManager\Factory\InvokableFactory;

use function gettype;
use function is_object;
use function sprintf;

/**
* Plugin manager implementation for translation placeholder compilers.
*
* Enforces that placeholder compilers retrieved are either instances of
* Placeholder\PlaceholderInterface. Additionally, it registers a number
* of default placeholder compilers.
*
* @template InstanceType of Placeholder\PlaceholderInterface
* @extends AbstractPluginManager<InstanceType>
* @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
));
}
}
79 changes: 79 additions & 0 deletions src/Translator/PlaceholderPluginManagerFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace Laminas\I18n\Translator;

use Laminas\ServiceManager\Config;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Laminas\ServiceManager\ServiceLocatorInterface;
use Laminas\ServiceManager\ServiceManager;
use Psr\Container\ContainerInterface;

use function is_array;

/** @psalm-import-type ServiceManagerConfiguration from ServiceManager */
class PlaceholderPluginManagerFactory implements FactoryInterface
{
/**
* Create and return a PlaceholderPluginManager.
*
* @param string $name
* @param array<string, mixed>|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;
}
}
Loading

0 comments on commit f5413d6

Please sign in to comment.