diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
index 6ee2181bdc..991627163a 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 c32c80d61c..5675e56726 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\Encoder\EncoderInterface;
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 @@ class FrameworkExtension extends Extension
}
}
+ // 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 a937dc9ac0..64ab0df39c 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 0000000000..943fda4b6b
--- /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 0000000000..311b3ed5ec
--- /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 0000000000..15ff182c6f
--- /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 0000000000..f80091b831
--- /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 0000000000..b446d60a13
--- /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 24c8a642d9..045ce0af9d 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 c0002e431d..ee81f60022 100644
--- a/src/Symfony/Component/String/AbstractUnicodeString.php
+++ b/src/Symfony/Component/String/AbstractUnicodeString.php
@@ -331,18 +331,6 @@ abstract class AbstractUnicodeString extends AbstractString
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 0000000000..e359f823a2
--- /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 0000000000..35d96d044c
--- /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 0000000000..d796dde11b
--- /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', '_'));
+ }
+}