Skip to content

Commit cfc0e50

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

File tree

3 files changed

+160
-0
lines changed

3 files changed

+160
-0
lines changed

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

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)