Skip to content

Commit 6d83471

Browse files
committed
Introduce a modern, JSX-like syntax for TwigComponents
1 parent e653f48 commit 6d83471

File tree

9 files changed

+203
-6
lines changed

9 files changed

+203
-6
lines changed

src/TwigComponent/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.21.0
4+
5+
- Introduce an experimental JSX-like syntax for TwigComponents, making `<twig:` prefix optional
6+
37
## 2.20.0
48

59
- Add Anonymous Component support for 3rd-party bundles #2019

src/TwigComponent/doc/index.rst

+22
Original file line numberDiff line numberDiff line change
@@ -1719,6 +1719,28 @@ Pass the name of some component as an argument to print its details:
17191719
| | int $min = 10 |
17201720
+---------------------------------------------------+-----------------------------------+
17211721
1722+
Short Syntax
1723+
------------
1724+
1725+
An experimental short syntax is available for components, and has been introduced in 2.21.
1726+
1727+
This experimental mode make the `<twig:` prefix optional, and allows you to call the component directly by its name
1728+
(with the first letter capitalized).
1729+
1730+
.. code-block:: html+twig
1731+
1732+
<Acme:Button type="primary">
1733+
Click me
1734+
</Acme:Button>
1735+
1736+
If you want to enabled this mode, you can do so by adding the following configuration:
1737+
1738+
.. code-block:: yaml
1739+
1740+
# config/packages/twig_component.yaml
1741+
twig_component:
1742+
short_syntax: true
1743+
17221744
Contributing
17231745
------------
17241746

src/TwigComponent/src/Command/TwigComponentDebugCommand.php

+16
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public function __construct(
3939
private Environment $twig,
4040
private readonly array $componentClassMap,
4141
?string $anonymousDirectory = null,
42+
private readonly ?bool $withShortSyntax = false,
43+
private readonly ?bool $withProfiler = false,
4244
) {
4345
parent::__construct();
4446
$this->anonymousDirectory = $anonymousDirectory ?? 'components';
@@ -85,6 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8587

8688
$components = $this->findComponents();
8789
$this->displayComponentsTable($io, $components);
90+
$this->displayDetailsAboutConfiguration($io);
8891

8992
return Command::SUCCESS;
9093
}
@@ -356,4 +359,17 @@ private function getAnonymousComponentProperties(ComponentMetadata $metadata): a
356359

357360
return $properties;
358361
}
362+
363+
private function displayDetailsAboutConfiguration(SymfonyStyle $io): void
364+
{
365+
$io->section('Configuration of TwigComponent');
366+
$io->table(
367+
['Configuration', 'Current value'],
368+
[
369+
['anonymous_template_directory', $this->anonymousDirectory],
370+
['short_syntax', $this->withShortSyntax ? 'enabled' : 'disabled'],
371+
['profiler', $this->withProfiler ? 'enabled' : 'disabled'],
372+
]
373+
);
374+
}
359375
}

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

+16
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,30 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) {
129129
;
130130

131131
$container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
132+
$container->getDefinition('ux.twig_component.twig.lexer')
133+
->addMethodCall('enableShortSyntax', [$config['short_syntax']]);
132134

133135
$container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class)
134136
->setDecoratedService(new Reference('twig.configurator.environment'))
135137
->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]);
136138

139+
// Currently, the ComponentLexer is not injected into the TwigEnvironmentConfigurator, but built directly in the
140+
// code (with a new ComponentLexer($environment)).
141+
// We cannot change this behavior without a major refactoring : environment is currently configured at runtime.
142+
// So we add setters for our required options
143+
// This should be improved in the future : currently, some parameters of the ComponentLexer are not settable
144+
$container->getDefinition('ux.twig_component.twig.environment_configurator')
145+
->addMethodCall('enabledShortSyntax', [$config['short_syntax']]);
146+
137147
$container->register('ux.twig_component.command.debug', TwigComponentDebugCommand::class)
138148
->setArguments([
139149
new Parameter('twig.default_path'),
140150
new Reference('ux.twig_component.component_factory'),
141151
new Reference('twig'),
142152
new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
143153
$config['anonymous_template_directory'],
154+
$config['short_syntax'],
155+
$config['profiler'],
144156
])
145157
->addTag('console.command')
146158
;
@@ -217,6 +229,10 @@ public function getConfigTreeBuilder(): TreeBuilder
217229
->info('Enables the profiler for Twig Component (in debug mode)')
218230
->defaultValue('%kernel.debug%')
219231
->end()
232+
->booleanNode('short_syntax')
233+
->info('Enables the short syntax for Twig Components (the <twig: prefix is optional)')
234+
->defaultValue(false)
235+
->end()
220236
->scalarNode('controllers_json')
221237
->setDeprecated('symfony/ux-twig-component', '2.18', 'The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0.')
222238
->defaultNull()

src/TwigComponent/src/Twig/ComponentLexer.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
*/
2727
class ComponentLexer extends Lexer
2828
{
29+
private $withShortSyntax = false;
30+
2931
public function tokenize(Source $source): TokenStream
3032
{
31-
$preLexer = new TwigPreLexer();
33+
$preLexer = new TwigPreLexer(withShortSyntax: $this->withShortSyntax);
3234
$preparsed = $preLexer->preLexComponents($source->getCode());
3335

3436
return parent::tokenize(
@@ -39,4 +41,9 @@ public function tokenize(Source $source): TokenStream
3941
)
4042
);
4143
}
44+
45+
public function enabledShortSyntax(bool $withShortSyntax = true): void
46+
{
47+
$this->withShortSyntax = $withShortSyntax;
48+
}
4249
}

src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php

+13-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
*/
2323
class TwigEnvironmentConfigurator
2424
{
25+
private bool $withShortSyntax = false;
26+
2527
public function __construct(
2628
private readonly EnvironmentConfigurator $decorated,
2729
) {
@@ -31,12 +33,22 @@ public function configure(Environment $environment): void
3133
{
3234
$this->decorated->configure($environment);
3335

34-
$environment->setLexer(new ComponentLexer($environment));
36+
$componentLexer = new ComponentLexer($environment);
37+
$componentLexer->enabledShortSyntax($this->withShortSyntax);
38+
$environment->setLexer($componentLexer);
3539

3640
if (class_exists(EscaperRuntime::class)) {
3741
$environment->getRuntime(EscaperRuntime::class)->addSafeClass(ComponentAttributes::class, ['html']);
3842
} elseif ($environment->hasExtension(EscaperExtension::class)) {
3943
$environment->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
4044
}
4145
}
46+
47+
/**
48+
* This method should be replaced by a proper autowiring configuration.
49+
*/
50+
public function enabledShortSyntax(bool $withShortSyntax = true): void
51+
{
52+
$this->withShortSyntax = $withShortSyntax;
53+
}
4254
}

src/TwigComponent/src/Twig/TwigPreLexer.php

+21-4
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Twig\Lexer;
1616

1717
/**
18-
* Rewrites <twig:component> syntaxes to {% component %} syntaxes.
18+
* Rewrites <twig:component> or <Component> syntaxes to {% component %} syntaxes.
1919
*/
2020
class TwigPreLexer
2121
{
@@ -28,17 +28,34 @@ class TwigPreLexer
2828
*/
2929
private array $currentComponents = [];
3030

31-
public function __construct(int $startingLine = 1)
31+
public function __construct(int $startingLine = 1, private readonly bool $withShortSyntax = false)
3232
{
3333
$this->line = $startingLine;
3434
}
3535

3636
public function preLexComponents(string $input): string
3737
{
38-
if (!str_contains($input, '<twig:')) {
38+
// tag may be:
39+
// - long: <twig:componentName>
40+
// - short (jsx like): <ComponentName> (with a capital letter)
41+
42+
$isLongSyntax = str_contains($input, '<twig:');
43+
$isShortSyntax = $this->withShortSyntax && preg_match_all('/<([A-Z][a-zA-Z0-9_:-]+)([^>]*)>/', $input, $matches, \PREG_SET_ORDER);
44+
45+
if (!$isLongSyntax && !$isShortSyntax) {
3946
return $input;
4047
}
4148

49+
if ($isShortSyntax) {
50+
$componentNames = array_map(fn ($match) => $match[1], $matches);
51+
$componentNames = array_unique(array_filter($componentNames));
52+
53+
// To simplify things in the rest of the class, we replace the component name with twig:<componentName>
54+
foreach ($componentNames as $componentName) {
55+
$input = preg_replace('!<(/?)'.preg_quote($componentName).'!', '<$1twig:'.lcfirst($componentName), $input);
56+
}
57+
}
58+
4259
$this->input = $input = str_replace(["\r\n", "\r"], "\n", $input);
4360
$this->length = \strlen($input);
4461
$output = '';
@@ -394,7 +411,7 @@ private function consumeBlock(string $componentName): string
394411
}
395412
$blockContents = $this->consumeUntilEndBlock();
396413

397-
$subLexer = new self($this->line);
414+
$subLexer = new self($this->line, $this->withShortSyntax);
398415
$output .= $subLexer->preLexComponents($blockContents);
399416

400417
$this->consume($closingTag);

src/TwigComponent/tests/Integration/Command/TwigComponentDebugCommandTest.php

+13
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,17 @@ private function tableDisplayCheck(string $display): void
241241
$this->assertStringContainsString('Template', $display);
242242
$this->assertStringContainsString('Properties', $display);
243243
}
244+
245+
public function testDisplayDetailsAboutConfiguration(): void
246+
{
247+
$commandTester = $this->createCommandTester();
248+
$commandTester->execute([]);
249+
250+
$commandTester->assertCommandIsSuccessful();
251+
252+
$display = $commandTester->getDisplay();
253+
254+
$this->assertStringContainsString('anonymous_template_directory components', $display);
255+
$this->assertStringContainsString('short_syntax disabled', $display);
256+
}
244257
}

src/TwigComponent/tests/Unit/TwigPreLexerTest.php

+90
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ public function testPreLex(string $input, string $expectedOutput): void
2626
$this->assertSame($expectedOutput, $lexer->preLexComponents($input));
2727
}
2828

29+
/**
30+
* @dataProvider getLexTestsWhithShortOptions
31+
*/
32+
public function testPreLexWithShortSyntax(string $input, string $expectedOutput): void
33+
{
34+
$lexer = new TwigPreLexer(withShortSyntax: true);
35+
$this->assertSame($expectedOutput, $lexer->preLexComponents($input));
36+
}
37+
2938
/**
3039
* @dataProvider getInvalidSyntaxTests
3140
*/
@@ -376,6 +385,12 @@ public static function getLexTests(): iterable
376385
'<twig:foobar bar="baz" {{ ...attr }}>content</twig:foobar>',
377386
'{% component \'foobar\' with { bar: \'baz\', ...attr } %}{% block content %}content{% endblock %}{% endcomponent %}',
378387
];
388+
389+
yield 'jsx_component_simple_component_not_enabled_by_default' => [
390+
'<Foo />',
391+
'<Foo />',
392+
];
393+
379394
yield 'component_with_comment_line' => [
380395
"<twig:foo \n # bar \n />",
381396
'{{ component(\'foo\') }}',
@@ -437,4 +452,79 @@ public static function getLexTests(): iterable
437452
TWIG,
438453
];
439454
}
455+
456+
public function getLexTestsWhithShortOptions()
457+
{
458+
yield 'not_a_component' => [
459+
'<foo />',
460+
'<foo />',
461+
];
462+
463+
yield 'jsx_component_simple_component' => [
464+
'<Foo />',
465+
'{{ component(\'foo\') }}',
466+
];
467+
468+
yield 'jsx_component_attribute_with_no_value_and_no_attributes' => [
469+
'<Foo/>',
470+
'{{ component(\'foo\') }}',
471+
];
472+
473+
yield 'jsx_component_with_default_block_content' => [
474+
'<Foo>Foo</Foo>',
475+
'{% component \'foo\' %}{% block content %}Foo{% endblock %}{% endcomponent %}',
476+
];
477+
478+
yield 'jsx_component_with_default_block_that_holds_a_component_and_multi_blocks' => [
479+
'<Foo>Foo <twig:bar /><twig:block name="other_block">Other block</twig:block></Foo>',
480+
'{% component \'foo\' %}{% block content %}Foo {{ component(\'bar\') }}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}',
481+
];
482+
483+
yield 'jsx_component_with_character_:_on_his_name' => [
484+
'<Foo:bar></Foo:bar>',
485+
'{% component \'foo:bar\' %}{% endcomponent %}',
486+
];
487+
488+
yield 'jsx_component_with_character_-_on_his_name' => [
489+
'<Foo-bar></Foo-bar>',
490+
'{% component \'foo-bar\' %}{% endcomponent %}',
491+
];
492+
493+
yield 'jsx_component_with_character_._on_his_name' => [
494+
'<Foo.bar></Foo.bar>',
495+
'{% component \'foo.bar\' %}{% endcomponent %}',
496+
];
497+
498+
yield 'jsx_component_with_block' => [
499+
'<SuccessAlert>
500+
<Block name="alert_message">
501+
xxxx
502+
</Block>
503+
</SuccessAlert>',
504+
'{% component \'successAlert\' %}
505+
{% block alert_message %}
506+
xxxx
507+
{% endblock %}
508+
{% endcomponent %}',
509+
];
510+
511+
yield 'jsx_component_with_sub_blocks' => [
512+
'<SuccessAlert>
513+
<Message name="alert_message">
514+
<Icon name="success" />
515+
</Message>
516+
<Message name="alert_message">
517+
<Icon name="success" />
518+
</Message>
519+
</SuccessAlert>',
520+
'{% component \'successAlert\' %}
521+
{% block content %}{% component \'message\' with { name: \'alert_message\' } %}
522+
{% block content %}{{ component(\'icon\', { name: \'success\' }) }}
523+
{% endblock %}{% endcomponent %}
524+
{% component \'message\' with { name: \'alert_message\' } %}
525+
{% block content %}{{ component(\'icon\', { name: \'success\' }) }}
526+
{% endblock %}{% endcomponent %}
527+
{% endblock %}{% endcomponent %}',
528+
];
529+
}
440530
}

0 commit comments

Comments
 (0)