From 056d8ceed9f05fbfd18c32e060f5fc3acce8f3de Mon Sep 17 00:00:00 2001 From: Titouan Galopin Date: Fri, 27 Sep 2019 22:52:12 +0200 Subject: [PATCH] [String] Introduce a locale-aware Slugger in the String component with FrameworkBundle wiring --- .../Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../FrameworkExtension.php | 7 + .../Resources/config/services.xml | 6 + .../Slugger/SlugConstructArgService.php | 29 ++++ .../Functional/SluggerLocaleAwareTest.php | 28 ++++ .../Tests/Functional/app/Slugger/bundles.php | 18 +++ .../Tests/Functional/app/Slugger/config.yml | 14 ++ .../Tests/Functional/app/Slugger/services.yml | 6 + .../Bundle/FrameworkBundle/composer.json | 1 + .../String/AbstractUnicodeString.php | 12 -- .../Component/String/Slugger/AsciiSlugger.php | 136 ++++++++++++++++++ .../String/Slugger/SluggerInterface.php | 29 ++++ .../Component/String/Tests/SluggerTest.php | 49 +++++++ 13 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Slugger/SlugConstructArgService.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SluggerLocaleAwareTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/bundles.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/config.yml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/services.yml create mode 100644 src/Symfony/Component/String/Slugger/AsciiSlugger.php create mode 100644 src/Symfony/Component/String/Slugger/SluggerInterface.php create mode 100644 src/Symfony/Component/String/Tests/SluggerTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 6ee2181bdc82..991627163ad5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -19,6 +19,7 @@ CHANGELOG * Removed `SecurityUserValueResolver`, use `UserValueResolver` instead * Removed `routing.loader.service`. * Service route loaders must be tagged with `routing.route_loader`. + * Added `slugger` service and `SluggerInterface` alias 4.4.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c32c80d61c14..5675e56726db 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -105,6 +105,7 @@ use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\ConstraintValidatorInterface; @@ -194,6 +195,12 @@ public function load(array $configs, ContainerBuilder $container) } } + // If the slugger is used but the String component is not available, we should throw an error + if (!class_exists(SluggerInterface::class)) { + $container->register('slugger', 'stdClass') + ->addError('You cannot use the "slugger" since the String component is not installed. Try running "composer require symfony/string".'); + } + if (isset($config['secret'])) { $container->setParameter('kernel.secret', $config['secret']); } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml index a937dc9ac004..64ab0df39c2b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.xml @@ -120,5 +120,11 @@ + + + %kernel.default_locale% + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Slugger/SlugConstructArgService.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Slugger/SlugConstructArgService.php new file mode 100644 index 000000000000..943fda4b6b9e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Slugger/SlugConstructArgService.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Slugger; + +use Symfony\Component\String\Slugger\SluggerInterface; + +class SlugConstructArgService +{ + private $slugger; + + public function __construct(SluggerInterface $slugger) + { + $this->slugger = $slugger; + } + + public function hello(): string + { + return $this->slugger->slug('Стойността трябва да бъде лъжа'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SluggerLocaleAwareTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SluggerLocaleAwareTest.php new file mode 100644 index 000000000000..311b3ed5ec97 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SluggerLocaleAwareTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +/** + * @group functional + */ +class SluggerLocaleAwareTest extends AbstractWebTestCase +{ + public function testLocalizedSlugger() + { + $kernel = static::createKernel(['test_case' => 'Slugger', 'root_config' => 'config.yml']); + $kernel->boot(); + + $service = $kernel->getContainer()->get('Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Slugger\SlugConstructArgService'); + + $this->assertSame('Stoinostta-tryabva-da-bude-luzha', $service->hello()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/bundles.php new file mode 100644 index 000000000000..15ff182c6fed --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/bundles.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\TestBundle; + +return [ + new FrameworkBundle(), + new TestBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/config.yml new file mode 100644 index 000000000000..f80091b831e0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/config.yml @@ -0,0 +1,14 @@ +imports: + - { resource: ../config/default.yml } + - { resource: services.yml } + +framework: + secret: '%secret%' + default_locale: '%env(LOCALE)%' + translator: + fallbacks: + - '%env(LOCALE)%' + +parameters: + env(LOCALE): bg + secret: test diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/services.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/services.yml new file mode 100644 index 000000000000..b446d60a13b5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Slugger/services.yml @@ -0,0 +1,6 @@ +services: + _defaults: + public: true + + Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Slugger\SlugConstructArgService: + arguments: ['@slugger'] diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 24c8a642d98b..045ce0af9d3e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -50,6 +50,7 @@ "symfony/security-http": "^4.4|^5.0", "symfony/serializer": "^4.4|^5.0", "symfony/stopwatch": "^4.4|^5.0", + "symfony/string": "^5.0", "symfony/translation": "^5.0", "symfony/twig-bundle": "^4.4|^5.0", "symfony/validator": "^4.4|^5.0", diff --git a/src/Symfony/Component/String/AbstractUnicodeString.php b/src/Symfony/Component/String/AbstractUnicodeString.php index c0002e431def..ee81f6002270 100644 --- a/src/Symfony/Component/String/AbstractUnicodeString.php +++ b/src/Symfony/Component/String/AbstractUnicodeString.php @@ -331,18 +331,6 @@ public function replaceMatches(string $fromPattern, $to): parent return $str; } - /** - * @return static - */ - public function slug(string $separator = '-'): self - { - return $this - ->ascii() - ->replace('@', $separator.'at'.$separator) - ->replaceMatches('/[^A-Za-z0-9]++/', $separator) - ->trim($separator); - } - public function snake(): parent { $str = $this->camel()->title(); diff --git a/src/Symfony/Component/String/Slugger/AsciiSlugger.php b/src/Symfony/Component/String/Slugger/AsciiSlugger.php new file mode 100644 index 000000000000..e359f823a203 --- /dev/null +++ b/src/Symfony/Component/String/Slugger/AsciiSlugger.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Slugger; + +use Symfony\Component\String\AbstractUnicodeString; +use Symfony\Component\String\GraphemeString; +use Symfony\Contracts\Translation\LocaleAwareInterface; + +/** + * @author Titouan Galopin + * + * @experimental in 5.0 + */ +class AsciiSlugger implements SluggerInterface, LocaleAwareInterface +{ + private const LOCALE_TO_TRANSLITERATOR_ID = [ + 'am' => 'Amharic-Latin', + 'ar' => 'Arabic-Latin', + 'az' => 'Azerbaijani-Latin', + 'be' => 'Belarusian-Latin', + 'bg' => 'Bulgarian-Latin', + 'bn' => 'Bengali-Latin', + 'de' => 'de-ASCII', + 'el' => 'Greek-Latin', + 'fa' => 'Persian-Latin', + 'he' => 'Hebrew-Latin', + 'hy' => 'Armenian-Latin', + 'ka' => 'Georgian-Latin', + 'kk' => 'Kazakh-Latin', + 'ky' => 'Kirghiz-Latin', + 'ko' => 'Korean-Latin', + 'mk' => 'Macedonian-Latin', + 'mn' => 'Mongolian-Latin', + 'or' => 'Oriya-Latin', + 'ps' => 'Pashto-Latin', + 'ru' => 'Russian-Latin', + 'sr' => 'Serbian-Latin', + 'sr_Cyrl' => 'Serbian-Latin', + 'th' => 'Thai-Latin', + 'tk' => 'Turkmen-Latin', + 'uk' => 'Ukrainian-Latin', + 'uz' => 'Uzbek-Latin', + 'zh' => 'Han-Latin', + ]; + + private $defaultLocale; + + /** + * Cache of transliterators per locale. + * + * @var \Transliterator[] + */ + private $transliterators = []; + + public function __construct(string $defaultLocale = null) + { + $this->defaultLocale = $defaultLocale; + } + + /** + * {@inheritdoc} + */ + public function setLocale($locale) + { + $this->defaultLocale = $locale; + } + + /** + * {@inheritdoc} + */ + public function getLocale() + { + return $this->defaultLocale; + } + + /** + * {@inheritdoc} + */ + public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString + { + $locale = $locale ?? $this->defaultLocale; + + $transliterator = []; + if ('de' === $locale || 0 === strpos($locale, 'de_')) { + // Use the shortcut for German in GraphemeString::ascii() if possible (faster and no requirement on intl) + $transliterator = ['de-ASCII']; + } elseif (\function_exists('transliterator_transliterate') && $locale) { + $transliterator = (array) $this->createTransliterator($locale); + } + + return (new GraphemeString($string)) + ->ascii($transliterator) + ->replace('@', $separator.'at'.$separator) + ->replaceMatches('/[^A-Za-z0-9]++/', $separator) + ->trim($separator) + ; + } + + private function createTransliterator(string $locale): ?\Transliterator + { + if (isset($this->transliterators[$locale])) { + return $this->transliterators[$locale]; + } + + // Exact locale supported, cache and return + if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$locale] ?? null) { + return $this->transliterators[$locale] = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); + } + + // Locale not supported and no parent, fallback to any-latin + if (false === $str = strrchr($locale, '_')) { + return null; + } + + // Try to use the parent locale (ie. try "de" for "de_AT") and cache both locales + $parent = substr($locale, 0, -\strlen($str)); + + if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$parent] ?? null) { + $transliterator = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); + $this->transliterators[$locale] = $this->transliterators[$parent] = $transliterator; + + return $transliterator; + } + + return null; + } +} diff --git a/src/Symfony/Component/String/Slugger/SluggerInterface.php b/src/Symfony/Component/String/Slugger/SluggerInterface.php new file mode 100644 index 000000000000..35d96d044ce3 --- /dev/null +++ b/src/Symfony/Component/String/Slugger/SluggerInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Slugger; + +use Symfony\Component\String\AbstractUnicodeString; + +/** + * Creates a URL-friendly slug from a given string. + * + * @author Titouan Galopin + * + * @experimental in 5.0 + */ +interface SluggerInterface +{ + /** + * Creates a slug for the given string and locale, using appropriate transliteration when needed. + */ + public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString; +} diff --git a/src/Symfony/Component/String/Tests/SluggerTest.php b/src/Symfony/Component/String/Tests/SluggerTest.php new file mode 100644 index 000000000000..d796dde11b72 --- /dev/null +++ b/src/Symfony/Component/String/Tests/SluggerTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\String\Slugger\AsciiSlugger; + +class SluggerTest extends TestCase +{ + /** + * @requires extension intl + * @dataProvider provideSlug + */ + public function testSlug(string $string, string $locale, string $expectedSlug) + { + $slugger = new AsciiSlugger($locale); + + $this->assertSame($expectedSlug, (string) $slugger->slug($string)); + } + + public static function provideSlug(): array + { + return [ + ['Стойността трябва да бъде лъжа', 'bg', 'Stoinostta-tryabva-da-bude-luzha'], + ['Dieser Wert sollte größer oder gleich', 'de', 'Dieser-Wert-sollte-groesser-oder-gleich'], + ['Dieser Wert sollte größer oder gleich', 'de_AT', 'Dieser-Wert-sollte-groesser-oder-gleich'], + ['Αυτή η τιμή πρέπει να είναι ψευδής', 'el', 'Avti-i-timi-prepi-na-inai-psevdhis'], + ['该变量的值应为', 'zh', 'gai-bian-liang-de-zhi-ying-wei'], + ['該變數的值應為', 'zh_TW', 'gai-bian-shu-de-zhi-ying-wei'], + ]; + } + + public function testSeparatorWithoutLocale() + { + $slugger = new AsciiSlugger(); + + $this->assertSame('hello-world', (string) $slugger->slug('hello world')); + $this->assertSame('hello_world', (string) $slugger->slug('hello world', '_')); + } +}