Skip to content

Commit

Permalink
[String] Introduce a locale-aware Slugger in the String component wit…
Browse files Browse the repository at this point in the history
…h FrameworkBundle wiring
  • Loading branch information
tgalopin committed Oct 2, 2019
1 parent 4a547c5 commit 056d8ce
Show file tree
Hide file tree
Showing 13 changed files with 324 additions and 12 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Expand Up @@ -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
-----
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}
Expand Down
Expand Up @@ -120,5 +120,11 @@
<argument type="service" id="request_stack" />
<tag name="kernel.event_subscriber" />
</service>

<service id="slugger" class="Symfony\Component\String\Slugger\AsciiSlugger">
<argument>%kernel.default_locale%</argument>
<tag name="kernel.locale_aware" />
</service>
<service id="Symfony\Component\String\Slugger\SluggerInterface" alias="slugger" />
</services>
</container>
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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('Стойността трябва да бъде лъжа');
}
}
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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());
}
}
@@ -0,0 +1,18 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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(),
];
@@ -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
@@ -0,0 +1,6 @@
services:
_defaults:
public: true

Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Slugger\SlugConstructArgService:
arguments: ['@slugger']
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/composer.json
Expand Up @@ -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",
Expand Down
12 changes: 0 additions & 12 deletions src/Symfony/Component/String/AbstractUnicodeString.php
Expand Up @@ -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();
Expand Down
136 changes: 136 additions & 0 deletions src/Symfony/Component/String/Slugger/AsciiSlugger.php
@@ -0,0 +1,136 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <galopintitouan@gmail.com>
*
* @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;
}
}
29 changes: 29 additions & 0 deletions src/Symfony/Component/String/Slugger/SluggerInterface.php
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <galopintitouan@gmail.com>
*
* @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;
}
49 changes: 49 additions & 0 deletions src/Symfony/Component/String/Tests/SluggerTest.php
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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', '_'));
}
}

0 comments on commit 056d8ce

Please sign in to comment.