Skip to content

Commit bb15214

Browse files
committed
[String] Introduce a locale-aware Slugger in the String component with FrameworkBundle wiring
1 parent a7d20bf commit bb15214

File tree

4 files changed

+214
-12
lines changed

4 files changed

+214
-12
lines changed

AbstractUnicodeString.php

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -331,18 +331,6 @@ public function replaceMatches(string $fromPattern, $to): parent
331331
return $str;
332332
}
333333

334-
/**
335-
* @return static
336-
*/
337-
public function slug(string $separator = '-'): self
338-
{
339-
return $this
340-
->ascii()
341-
->replace('@', $separator.'at'.$separator)
342-
->replaceMatches('/[^A-Za-z0-9]++/', $separator)
343-
->trim($separator);
344-
}
345-
346334
public function snake(): parent
347335
{
348336
$str = $this->camel()->title();

Slugger/AsciiSlugger.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\String\Slugger;
13+
14+
use Symfony\Component\String\AbstractUnicodeString;
15+
use Symfony\Component\String\GraphemeString;
16+
use Symfony\Contracts\Translation\LocaleAwareInterface;
17+
18+
/**
19+
* @author Titouan Galopin <galopintitouan@gmail.com>
20+
*
21+
* @experimental in 5.0
22+
*/
23+
class AsciiSlugger implements SluggerInterface, LocaleAwareInterface
24+
{
25+
private const LOCALE_TO_TRANSLITERATOR_ID = [
26+
'am' => 'Amharic-Latin',
27+
'ar' => 'Arabic-Latin',
28+
'az' => 'Azerbaijani-Latin',
29+
'be' => 'Belarusian-Latin',
30+
'bg' => 'Bulgarian-Latin',
31+
'bn' => 'Bengali-Latin',
32+
'de' => 'de-ASCII',
33+
'el' => 'Greek-Latin',
34+
'fa' => 'Persian-Latin',
35+
'he' => 'Hebrew-Latin',
36+
'hy' => 'Armenian-Latin',
37+
'ka' => 'Georgian-Latin',
38+
'kk' => 'Kazakh-Latin',
39+
'ky' => 'Kirghiz-Latin',
40+
'ko' => 'Korean-Latin',
41+
'mk' => 'Macedonian-Latin',
42+
'mn' => 'Mongolian-Latin',
43+
'or' => 'Oriya-Latin',
44+
'ps' => 'Pashto-Latin',
45+
'ru' => 'Russian-Latin',
46+
'sr' => 'Serbian-Latin',
47+
'sr_Cyrl' => 'Serbian-Latin',
48+
'th' => 'Thai-Latin',
49+
'tk' => 'Turkmen-Latin',
50+
'uk' => 'Ukrainian-Latin',
51+
'uz' => 'Uzbek-Latin',
52+
'zh' => 'Han-Latin',
53+
];
54+
55+
private $defaultLocale;
56+
57+
/**
58+
* Cache of transliterators per locale.
59+
*
60+
* @var \Transliterator[]
61+
*/
62+
private $transliterators = [];
63+
64+
public function __construct(string $defaultLocale = null)
65+
{
66+
$this->defaultLocale = $defaultLocale;
67+
}
68+
69+
/**
70+
* {@inheritdoc}
71+
*/
72+
public function setLocale($locale)
73+
{
74+
$this->defaultLocale = $locale;
75+
}
76+
77+
/**
78+
* {@inheritdoc}
79+
*/
80+
public function getLocale()
81+
{
82+
return $this->defaultLocale;
83+
}
84+
85+
/**
86+
* {@inheritdoc}
87+
*/
88+
public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString
89+
{
90+
$locale = $locale ?? $this->defaultLocale;
91+
92+
$transliterator = [];
93+
if ('de' === $locale || 0 === strpos($locale, 'de_')) {
94+
// Use the shortcut for German in GraphemeString::ascii() if possible (faster and no requirement on intl)
95+
$transliterator = ['de-ASCII'];
96+
} elseif (\function_exists('transliterator_transliterate') && $locale) {
97+
$transliterator = (array) $this->createTransliterator($locale);
98+
}
99+
100+
return (new GraphemeString($string))
101+
->ascii($transliterator)
102+
->replace('@', $separator.'at'.$separator)
103+
->replaceMatches('/[^A-Za-z0-9]++/', $separator)
104+
->trim($separator)
105+
;
106+
}
107+
108+
private function createTransliterator(string $locale): ?\Transliterator
109+
{
110+
if (isset($this->transliterators[$locale])) {
111+
return $this->transliterators[$locale];
112+
}
113+
114+
// Exact locale supported, cache and return
115+
if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$locale] ?? null) {
116+
return $this->transliterators[$locale] = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id);
117+
}
118+
119+
// Locale not supported and no parent, fallback to any-latin
120+
if (false === $str = strrchr($locale, '_')) {
121+
return null;
122+
}
123+
124+
// Try to use the parent locale (ie. try "de" for "de_AT") and cache both locales
125+
$parent = substr($locale, 0, -\strlen($str));
126+
127+
if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$parent] ?? null) {
128+
$transliterator = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id);
129+
$this->transliterators[$locale] = $this->transliterators[$parent] = $transliterator;
130+
131+
return $transliterator;
132+
}
133+
134+
return null;
135+
}
136+
}

Slugger/SluggerInterface.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\String\Slugger;
13+
14+
use Symfony\Component\String\AbstractUnicodeString;
15+
16+
/**
17+
* Creates a URL-friendly slug from a given string.
18+
*
19+
* @author Titouan Galopin <galopintitouan@gmail.com>
20+
*
21+
* @experimental in 5.0
22+
*/
23+
interface SluggerInterface
24+
{
25+
/**
26+
* Creates a slug for the given string and locale, using appropriate transliteration when needed.
27+
*/
28+
public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString;
29+
}

Tests/SluggerTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\String\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\String\Slugger\AsciiSlugger;
16+
17+
class SluggerTest extends TestCase
18+
{
19+
/**
20+
* @requires extension intl
21+
* @dataProvider provideSlug
22+
*/
23+
public function testSlug(string $string, string $locale, string $expectedSlug)
24+
{
25+
$slugger = new AsciiSlugger($locale);
26+
27+
$this->assertSame($expectedSlug, (string) $slugger->slug($string));
28+
}
29+
30+
public static function provideSlug(): array
31+
{
32+
return [
33+
['Стойността трябва да бъде лъжа', 'bg', 'Stoinostta-tryabva-da-bude-luzha'],
34+
['Dieser Wert sollte größer oder gleich', 'de', 'Dieser-Wert-sollte-groesser-oder-gleich'],
35+
['Dieser Wert sollte größer oder gleich', 'de_AT', 'Dieser-Wert-sollte-groesser-oder-gleich'],
36+
['Αυτή η τιμή πρέπει να είναι ψευδής', 'el', 'Avti-i-timi-prepi-na-inai-psevdhis'],
37+
['该变量的值应为', 'zh', 'gai-bian-liang-de-zhi-ying-wei'],
38+
['該變數的值應為', 'zh_TW', 'gai-bian-shu-de-zhi-ying-wei'],
39+
];
40+
}
41+
42+
public function testSeparatorWithoutLocale()
43+
{
44+
$slugger = new AsciiSlugger();
45+
46+
$this->assertSame('hello-world', (string) $slugger->slug('hello world'));
47+
$this->assertSame('hello_world', (string) $slugger->slug('hello world', '_'));
48+
}
49+
}

0 commit comments

Comments
 (0)