Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 60b74bd

Browse files
authoredJun 23, 2025··
fix: slugs containing special chars (#45)
1 parent 56ede38 commit 60b74bd

File tree

3 files changed

+125
-2
lines changed

3 files changed

+125
-2
lines changed
 

‎src/ContentType/Listener/ResolveControllerListener.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Storyblok\Bundle\ContentType\Exception\ContentTypeControllerNotFoundException;
2727
use Storyblok\Bundle\ContentType\Exception\InvalidStoryException;
2828
use Storyblok\Bundle\ContentType\Exception\StoryNotFoundException;
29+
use Storyblok\Bundle\ContentType\UnicodeSlug;
2930
use Storyblok\Bundle\Routing\Route;
3031
use Symfony\Component\HttpFoundation\Request;
3132
use Symfony\Component\HttpFoundation\Response;
@@ -63,10 +64,10 @@ public function __invoke(ControllerEvent $event): void
6364
return;
6465
}
6566

66-
$slug = $params['slug'];
67+
$slug = new UnicodeSlug($params['slug']);
6768

6869
try {
69-
$response = $this->stories->bySlug($slug, new StoryRequest(
70+
$response = $this->stories->bySlug($slug->toString(), new StoryRequest(
7071
language: $request->getLocale(),
7172
version: Version::from($this->version),
7273
));

‎src/ContentType/UnicodeSlug.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of storyblok/symfony-bundle.
7+
*
8+
* (c) Storyblok GmbH <info@storyblok.com>
9+
* in cooperation with SensioLabs Deutschland <info@sensiolabs.de>
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
namespace Storyblok\Bundle\ContentType;
16+
17+
use OskarStark\Value\TrimmedNonEmptyString;
18+
use function Symfony\Component\String\u;
19+
20+
/**
21+
* @internal
22+
*
23+
* We must URL encode the slug value to ensure it is safe for use in URLs. Storyblok slugs can contain special
24+
* characters, spaces, and other characters that may not be URL-safe.
25+
*/
26+
final readonly class UnicodeSlug implements \Stringable
27+
{
28+
private string $value;
29+
30+
public function __construct(string $value)
31+
{
32+
$value = TrimmedNonEmptyString::fromString($value)->toString();
33+
34+
$urlEncodedValue = '';
35+
36+
foreach (u($value)->split('/') as $key => $part) {
37+
if (0 < $key) {
38+
$urlEncodedValue .= '/';
39+
}
40+
41+
$urlEncodedValue .= urlencode($part->toString());
42+
}
43+
44+
$this->value = u($urlEncodedValue)
45+
->trimEnd('/')
46+
->trimStart('/')
47+
->toString();
48+
}
49+
50+
public function __toString(): string
51+
{
52+
return $this->toString();
53+
}
54+
55+
public function toString(): string
56+
{
57+
return $this->value;
58+
}
59+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of storyblok/symfony-bundle.
7+
*
8+
* (c) Storyblok GmbH <info@storyblok.com>
9+
* in cooperation with SensioLabs Deutschland <info@sensiolabs.de>
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
namespace Storyblok\Bundle\Tests\Unit\ContentType;
16+
17+
use Ergebnis\DataProvider\StringProvider;
18+
use PHPUnit\Framework\Attributes\DataProvider;
19+
use PHPUnit\Framework\Attributes\DataProviderExternal;
20+
use PHPUnit\Framework\Attributes\Test;
21+
use PHPUnit\Framework\TestCase;
22+
use Storyblok\Bundle\ContentType\UnicodeSlug;
23+
use Storyblok\Bundle\Tests\Util\FakerTrait;
24+
25+
final class UnicodeSlugTest extends TestCase
26+
{
27+
use FakerTrait;
28+
29+
#[DataProvider('slugs')]
30+
#[Test]
31+
public function value(string $expected, string $value): void
32+
{
33+
self::assertSame($expected, (new UnicodeSlug($value))->toString());
34+
self::assertSame($expected, (new UnicodeSlug($value))->__toString());
35+
self::assertSame($expected, (string) new UnicodeSlug($value));
36+
}
37+
38+
/**
39+
* @return iterable<string, array{0: string, 1: string}>
40+
*/
41+
public static function slugs(): iterable
42+
{
43+
yield 'single word' => ['hello', 'hello'];
44+
yield 'with slash prefix' => ['hello', '/hello'];
45+
yield 'with trailing slash' => ['hello', 'hello/'];
46+
yield 'with dash' => ['hello-world', 'hello-world'];
47+
yield 'with underscore' => ['hello_world', 'hello_world'];
48+
yield 'with cyrillic characters' => ['%D0%BF%D1%80%D0%B8%D0%B2%D1%96%D1%82-%D1%81%D0%B2%D1%96%D1%82', 'привіт-світ'];
49+
yield 'with cyrillic nested path' => ['%D0%BC%D1%96%D0%B9-%D0%B1%D0%BB%D0%BE%D0%B3/%D0%BF%D1%80%D0%B8%D0%B2%D1%96%D1%82-%D1%81%D0%B2%D1%96%D1%82', 'мій-блог/привіт-світ'];
50+
yield 'with mandarin characters' => ['%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C', '你好世界'];
51+
yield 'with hindi characters' => ['%E0%A4%B9%E0%A5%88%E0%A4%B2%E0%A5%8B-%E0%A4%B5%E0%A4%B0%E0%A5%8D%E0%A4%B2%E0%A5%8D%E0%A4%A1', 'हैलो-वर्ल्ड'];
52+
}
53+
54+
#[DataProviderExternal(StringProvider::class, 'blank')]
55+
#[DataProviderExternal(StringProvider::class, 'empty')]
56+
#[Test]
57+
public function invalid(string $value): void
58+
{
59+
self::expectException(\InvalidArgumentException::class);
60+
61+
new UnicodeSlug($value);
62+
}
63+
}

0 commit comments

Comments
 (0)