Skip to content

Commit 4b368e6

Browse files
committed
Introduce stimulus_controller to ease Stimulus Values API usage
1 parent 4dc8ecb commit 4b368e6

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\Exception;
11+
12+
class MissingPackageException extends \LogicException
13+
{
14+
}

src/Resources/config/services.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
</argument>
3737
</service>
3838

39+
<service id="webpack_encore.twig_stimulus_extension" class="Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension">
40+
<tag name="twig.extension" />
41+
</service>
42+
3943
<service id="webpack_encore.entrypoint_lookup.cache_warmer" class="Symfony\WebpackEncoreBundle\CacheWarmer\EntrypointCacheWarmer">
4044
<tag name="kernel.cache_warmer" />
4145
<argument /> <!-- build list of entrypoint paths -->

src/Twig/StimulusTwigExtension.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\Twig;
11+
12+
use Symfony\WebpackEncoreBundle\Exception\MissingPackageException;
13+
use Twig\Environment;
14+
use Twig\Extension\AbstractExtension;
15+
use Twig\TwigFunction;
16+
17+
final class StimulusTwigExtension extends AbstractExtension
18+
{
19+
public function getFunctions()
20+
{
21+
return [
22+
new TwigFunction('stimulus_controller', [$this, 'renderStimulusController'], ['needs_environment' => true, 'is_safe' => ['all']]),
23+
];
24+
}
25+
26+
public function renderStimulusController(Environment $env, array $data): string
27+
{
28+
if (!$data) {
29+
return '';
30+
}
31+
32+
$controllers = [];
33+
$values = [];
34+
35+
foreach ($data as $controllerName => $controllerValues) {
36+
$controllerName = twig_escape_filter($env, $this->normalizeControllerName($controllerName), 'html_attr');
37+
$controllers[] = $controllerName;
38+
39+
foreach ($controllerValues as $key => $value) {
40+
if (!is_scalar($value)) {
41+
$value = json_encode($value);
42+
}
43+
44+
$key = twig_escape_filter($env, $this->normalizeKeyName($key), 'html_attr');
45+
$value = twig_escape_filter($env, $value, 'html_attr');
46+
47+
$values[] = 'data-'.$controllerName.'-'.$key.'-value="'.$value.'"';
48+
}
49+
}
50+
51+
return rtrim('data-controller="'.implode(' ', $controllers).'" '.implode(' ', $values));
52+
}
53+
54+
/**
55+
* Normalize a Stimulus controller name into its HTML equivalent (no special character and / becomes --).
56+
*
57+
* @see https://stimulus.hotwire.dev/reference/controllers
58+
*/
59+
private function normalizeControllerName(string $str): string
60+
{
61+
return preg_replace('/^@/', '', str_replace('_', '-', str_replace('/', '--', $str)));
62+
}
63+
64+
/**
65+
* Normalize a Stimulus Value API key into its HTML equivalent ("kebab case").
66+
* Backport features from symfony/string.
67+
*
68+
* @see https://stimulus.hotwire.dev/reference/values
69+
*/
70+
private function normalizeKeyName(string $str): string
71+
{
72+
// Adapted from ByteString::camel
73+
$str = ucfirst(str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $str))));
74+
75+
// Adapted from ByteString::snake
76+
return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1-\2', $str));
77+
}
78+
}

tests/IntegrationTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
2929
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
3030
use Symfony\WebpackEncoreBundle\CacheWarmer\EntrypointCacheWarmer;
31+
use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension;
3132
use Symfony\WebpackEncoreBundle\WebpackEncoreBundle;
3233

3334
class IntegrationTest extends TestCase
@@ -178,6 +179,84 @@ public function testAutowireDefaultBuildArgument()
178179
$this->assertTrue(true);
179180
}
180181

182+
public function provideRenderStimulusController()
183+
{
184+
yield 'empty' => [
185+
'data' => [],
186+
'expected' => '',
187+
];
188+
189+
yield 'single-controller-no-data' => [
190+
'data' => [
191+
'my-controller' => [],
192+
],
193+
'expected' => 'data-controller="my-controller"',
194+
];
195+
196+
yield 'single-controller-scalar-data' => [
197+
'data' => [
198+
'my-controller' => [
199+
'myValue' => 'scalar-value',
200+
],
201+
],
202+
'expected' => 'data-controller="my-controller" data-my-controller-my-value-value="scalar-value"',
203+
];
204+
205+
yield 'single-controller-typed-data' => [
206+
'data' => [
207+
'my-controller' => [
208+
'boolean' => true,
209+
'number' => 4,
210+
'string' => 'str',
211+
],
212+
],
213+
'expected' => 'data-controller="my-controller" data-my-controller-boolean-value="1" data-my-controller-number-value="4" data-my-controller-string-value="str"',
214+
];
215+
216+
yield 'single-controller-nested-data' => [
217+
'data' => [
218+
'my-controller' => [
219+
'myValue' => ['nested' => 'array'],
220+
],
221+
],
222+
'expected' => 'data-controller="my-controller" data-my-controller-my-value-value="&#x7B;&quot;nested&quot;&#x3A;&quot;array&quot;&#x7D;"',
223+
];
224+
225+
yield 'multiple-controllers-scalar-data' => [
226+
'data' => [
227+
'my-controller' => [
228+
'myValue' => 'scalar-value',
229+
],
230+
'another-controller' => [
231+
'anotherValue' => 'scalar-value 2',
232+
],
233+
],
234+
'expected' => 'data-controller="my-controller another-controller" data-my-controller-my-value-value="scalar-value" data-another-controller-another-value-value="scalar-value&#x20;2"',
235+
];
236+
237+
yield 'normalize-names' => [
238+
'data' => [
239+
'@symfony/ux-dropzone/dropzone' => [
240+
'my"Key"' => true,
241+
],
242+
],
243+
'expected' => 'data-controller="symfony--ux-dropzone--dropzone" data-symfony--ux-dropzone--dropzone-my-key-value="1"',
244+
];
245+
}
246+
247+
/**
248+
* @dataProvider provideRenderStimulusController
249+
*/
250+
public function testRenderStimulusController(array $data, string $expected)
251+
{
252+
$kernel = new WebpackEncoreIntegrationTestKernel(true);
253+
$kernel->boot();
254+
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);
255+
256+
$extension = new StimulusTwigExtension();
257+
$this->assertSame($expected, $extension->renderStimulusController($twig, $data));
258+
}
259+
181260
private function getContainerFromBootedKernel(WebpackEncoreIntegrationTestKernel $kernel)
182261
{
183262
if ($kernel::VERSION_ID >= 40100) {

0 commit comments

Comments
 (0)