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 593b90c

Browse files
committedApr 5, 2025·
Introduce a Short Tags system for TwigComponents
1 parent e653f48 commit 593b90c

File tree

7 files changed

+380
-6
lines changed

7 files changed

+380
-6
lines changed
 

Diff for: ‎src/TwigComponent/CHANGELOG.md

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

3+
## 2.24.0
4+
5+
- Introduce an experimental Short tags system for TwigComponents, making `twig:` prefix optional #2662
6+
37
## 2.20.0
48

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

Diff for: ‎src/TwigComponent/doc/index.rst

+30
Original file line numberDiff line numberDiff line change
@@ -1719,6 +1719,36 @@ Pass the name of some component as an argument to print its details:
17191719
| | int $min = 10 |
17201720
+---------------------------------------------------+-----------------------------------+
17211721
1722+
Short Tags
1723+
----------
1724+
1725+
An experimental new short tag system, allowing the omission of the 'twig:' prefix in HTML tags, was introduced in version 2.24.
1726+
1727+
This mode allows you to omit the `twig:` prefix and reference components directly by their 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+
This is equivalent to:
1737+
1738+
.. code-block:: html+twig
1739+
1740+
<twig:Acme:Button type="primary">
1741+
Click me
1742+
</twig:Acme:Button>
1743+
1744+
To enable this feature, add the following configuration:
1745+
1746+
.. code-block:: yaml
1747+
1748+
# config/packages/twig_component.yaml
1749+
twig_component:
1750+
short_tags: true
1751+
17221752
Contributing
17231753
------------
17241754

Diff for: ‎src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

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

131131
$container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
132+
if (true === $config['short_tags']) {
133+
$container->getDefinition('ux.twig_component.twig.lexer')
134+
->addMethodCall('enableShortTags');
135+
}
132136

133137
$container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class)
134138
->setDecoratedService(new Reference('twig.configurator.environment'))
135139
->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]);
136140

141+
// Currently, the ComponentLexer is not injected into the TwigEnvironmentConfigurator, but built directly in the
142+
// code (with a new ComponentLexer($environment)).
143+
// We cannot change this behavior without a major refactoring : environment is currently configured at runtime.
144+
// So we add setters for our required options
145+
// This should be improved in the future: currently, parameters of the ComponentLexer are not injectables.
146+
if (true === $config['short_tags']) {
147+
$container->getDefinition('ux.twig_component.twig.environment_configurator')
148+
->addMethodCall('enabledShortTags');
149+
}
150+
137151
$container->register('ux.twig_component.command.debug', TwigComponentDebugCommand::class)
138152
->setArguments([
139153
new Parameter('twig.default_path'),
@@ -217,6 +231,10 @@ public function getConfigTreeBuilder(): TreeBuilder
217231
->info('Enables the profiler for Twig Component (in debug mode)')
218232
->defaultValue('%kernel.debug%')
219233
->end()
234+
->booleanNode('short_tags')
235+
->info('Enables the short syntax for Twig Components (the <twig: prefix is optional)')
236+
->defaultValue(false)
237+
->end()
220238
->scalarNode('controllers_json')
221239
->setDeprecated('symfony/ux-twig-component', '2.18', 'The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0.')
222240
->defaultNull()

Diff for: ‎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 bool $withShortTags = false;
30+
2931
public function tokenize(Source $source): TokenStream
3032
{
31-
$preLexer = new TwigPreLexer();
33+
$preLexer = new TwigPreLexer(withShortTags: $this->withShortTags);
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 enabledShortTags(): void
46+
{
47+
$this->withShortTags = true;
48+
}
4249
}

Diff for: ‎src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php

+17-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
*/
2323
class TwigEnvironmentConfigurator
2424
{
25+
private bool $withShortTags = false;
26+
2527
public function __construct(
2628
private readonly EnvironmentConfigurator $decorated,
2729
) {
@@ -31,12 +33,26 @@ 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+
38+
if ($this->withShortTags) {
39+
$componentLexer->enabledShortTags();
40+
}
41+
42+
$environment->setLexer($componentLexer);
3543

3644
if (class_exists(EscaperRuntime::class)) {
3745
$environment->getRuntime(EscaperRuntime::class)->addSafeClass(ComponentAttributes::class, ['html']);
3846
} elseif ($environment->hasExtension(EscaperExtension::class)) {
3947
$environment->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
4048
}
4149
}
50+
51+
/**
52+
* This method should be replaced by a proper autowiring configuration.
53+
*/
54+
public function enabledShortTags(): void
55+
{
56+
$this->withShortTags = true;
57+
}
4258
}

Diff for: ‎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 $withShortTags = 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+
// - prefixed: <twig:componentName>
40+
// - short (jsx like): <ComponentName> (with a capital letter)
41+
42+
$isPrefixedTags = str_contains($input, '<twig:');
43+
$isShortTags = $this->withShortTags && preg_match_all('/<([A-Z][a-zA-Z0-9_:-]+)([^>]*)>/', $input, $matches, \PREG_SET_ORDER);
44+
45+
if (!$isPrefixedTags && !$isShortTags) {
3946
return $input;
4047
}
4148

49+
if ($isShortTags) {
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->withShortTags);
398415
$output .= $subLexer->preLexComponents($blockContents);
399416

400417
$this->consume($closingTag);

Diff for: ‎src/TwigComponent/tests/Unit/TwigPreLexerTest.php

+282
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 testPreLexWithShortTags(string $input, string $expectedOutput): void
33+
{
34+
$lexer = new TwigPreLexer(withShortTags: 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,271 @@ public static function getLexTests(): iterable
437452
TWIG,
438453
];
439454
}
455+
456+
public static 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+
530+
yield 'jsx_component_with_multiple_nested_namespaces' => [
531+
'<Foo:Bar:Baz></Foo:Bar:Baz>',
532+
'{% component \'foo:Bar:Baz\' %}{% endcomponent %}',
533+
];
534+
535+
yield 'mixing_standard_and_jsx_components' => [
536+
'<Alert><twig:Button>Click me</twig:Button></Alert>',
537+
"{% component 'alert' %}{% block content %}{% component 'Button' %}{% block content %}Click me{% endblock %}{% endcomponent %}{% endblock %}{% endcomponent %}",
538+
];
539+
540+
yield 'jsx_component_with_dynamic_attributes' => [
541+
'<Alert :level="alertLevel" title="{{ title }}"></Alert>',
542+
'{% component \'alert\' with { level: alertLevel, title: (title) } %}{% endcomponent %}',
543+
];
544+
545+
yield 'jsx_component_with_spreading' => [
546+
'<Button {{ ...buttonAttrs }}>Click me</Button>',
547+
'{% component \'button\' with { ...buttonAttrs } %}{% block content %}Click me{% endblock %}{% endcomponent %}',
548+
];
549+
550+
yield 'jsx_component_with_named_blocks' => [
551+
'<Card><Block name="header">Title</Block><Block name="body">Content</Block></Card>',
552+
'{% component \'card\' %}{% block header %}Title{% endblock %}{% block body %}Content{% endblock %}{% endcomponent %}',
553+
];
554+
555+
yield 'nested_jsx_components_with_namespaces' => [
556+
'<UI:Layout><UI:Sidebar>Menu</UI:Sidebar><UI:Content>Page</UI:Content></UI:Layout>',
557+
"{% component 'uI:Layout' %}{% block content %}{% component 'uI:Sidebar' %}{% block content %}Menu{% endblock %}{% endcomponent %}{% component 'uI:Content' %}{% block content %}Page{% endblock %}{% endcomponent %}{% endblock %}{% endcomponent %}",
558+
];
559+
560+
yield 'normal_html_tags_not_transformed' => [
561+
'<div><span>Text</span><input type="text" /></div>',
562+
'<div><span>Text</span><input type="text" /></div>',
563+
];
564+
565+
yield 'lowercase_component_name_not_transformed' => [
566+
'<foo>Content</foo>',
567+
'<foo>Content</foo>',
568+
];
569+
570+
yield 'jsx_component_with_special_characters_in_attributes' => [
571+
'<Button data-testid="test-btn" aria-label="Click me"></Button>',
572+
'{% component \'button\' with { \'data-testid\': \'test-btn\', \'aria-label\': \'Click me\' } %}{% endcomponent %}',
573+
];
574+
575+
yield 'jsx_component_with_html_comments' => [
576+
'<Alert><!-- This is a comment --><Block name="title">Alert title</Block></Alert>',
577+
'{% component \'alert\' %}{% block content %}<!-- This is a comment -->{% endblock %}{% block title %}Alert title{% endblock %}{% endcomponent %}',
578+
];
579+
580+
yield 'jsx_component_with_boolean_attributes' => [
581+
'<Button disabled primary>Click me</Button>',
582+
'{% component \'button\' with { disabled: true, primary: true } %}{% block content %}Click me{% endblock %}{% endcomponent %}',
583+
];
584+
585+
yield 'jsx_component_with_whitespace' => [
586+
'<Button
587+
type="primary"
588+
size="large"
589+
>
590+
Submit
591+
</Button>',
592+
'{% component \'button\' with { type: \'primary\', size: \'large\' } %}
593+
{% block content %}Submit
594+
{% endblock %}{% endcomponent %}',
595+
];
596+
597+
yield 'jsx_component_with_numbers_in_name' => [
598+
'<Grid3x3>Content</Grid3x3>',
599+
'{% component \'grid3x3\' %}{% block content %}Content{% endblock %}{% endcomponent %}',
600+
];
601+
602+
yield 'jsx_component_with_complex_expressions' => [
603+
'<DataTable :items="items|filter(item => item.active)|sort((a, b) => a.name <=> b.name)" />',
604+
'{{ component(\'dataTable\', { items: items|filter(item => item.active)|sort((a, b) => a.name <=> b.name) }) }}',
605+
];
606+
607+
// Looks like HTML 5 custom elements
608+
yield 'jsx_component_similar_to_custom_element' => [
609+
'<Custom-Element data-value="test">Content</Custom-Element>',
610+
"{% component 'custom-Element' with { 'data-value': 'test' } %}{% block content %}Content{% endblock %}{% endcomponent %}",
611+
];
612+
613+
yield 'nested_jsx_components_with_same_name' => [
614+
'<Section><Section>Nested</Section></Section>',
615+
'{% component \'section\' %}{% block content %}{% component \'section\' %}{% block content %}Nested{% endblock %}{% endcomponent %}{% endblock %}{% endcomponent %}',
616+
];
617+
618+
yield 'jsx_component_with_embedded_twig_conditions' => [
619+
'<Card>{% if showTitle %}<Block name="title">Title</Block>{% endif %}</Card>',
620+
"{% component 'card' %}{% block content %}{% if showTitle %}{% endblock %}{% block title %}Title{% endblock %}{% block content %}{% endif %}{% endblock %}{% endcomponent %}",
621+
];
622+
623+
yield 'jsx_component_with_embedded_twig_loops' => [
624+
'<List>{% for item in items %}<Item :value="item">{{ item.name }}</Item>{% endfor %}</List>',
625+
'{% component \'list\' %}{% block content %}{% for item in items %}{% component \'item\' with { value: item } %}{% block content %}{{ item.name }}{% endblock %}{% endcomponent %}{% endfor %}{% endblock %}{% endcomponent %}',
626+
];
627+
628+
yield 'jsx_component_with_escaped_attribute_values' => [
629+
'<Alert message="This is a \'quoted\' message" />',
630+
"{{ component('alert', { message: 'This is a \'quoted\' message' }) }}",
631+
];
632+
633+
yield 'jsx_component_with_self_closing_html_in_content' => [
634+
'<Card><img src="image.jpg" /><hr/></Card>',
635+
'{% component \'card\' %}{% block content %}<img src="image.jpg" /><hr/>{% endblock %}{% endcomponent %}',
636+
];
637+
638+
yield 'jsx_component_with_array_and_object_attributes' => [
639+
'<Select :options="[\'option1\', \'option2\']" :config="{ multiselect: true }" />',
640+
'{{ component(\'select\', { options: [\'option1\', \'option2\'], config: { multiselect: true } }) }}',
641+
];
642+
643+
yield 'jsx_component_interpolation_inside_dynamic_attribute' => [
644+
'<Button :class="isActive ? \'active-{{ theme }}\' : \'inactive\'" />',
645+
"{{ component('button', { class: isActive ? 'active-{{ theme }}' : 'inactive' }) }}",
646+
];
647+
648+
yield 'jsx_component_with_mixed_case_name' => [
649+
'<DataTable sorting="asc">Content</DataTable>',
650+
'{% component \'dataTable\' with { sorting: \'asc\' } %}{% block content %}Content{% endblock %}{% endcomponent %}',
651+
];
652+
653+
yield 'jsx_component_with_namespace_and_mixed_case' => [
654+
'<App:UserProfile:Avatar size="medium" />',
655+
'{{ component(\'app:UserProfile:Avatar\', { size: \'medium\' }) }}',
656+
];
657+
658+
yield 'jsx_component_with_complex_twig_in_attributes' => [
659+
'<Form :errors="form.errors is defined ? form.errors : {}" :disabled="form.isSubmitting ?? false" />',
660+
'{{ component(\'form\', { errors: form.errors is defined ? form.errors : {}, disabled: form.isSubmitting ?? false }) }}',
661+
];
662+
663+
yield 'jsx_component_with_path_expression_in_attributes' => [
664+
'<Field :value="user.address.street" :error="errors.address.street|default(null)" />',
665+
'{{ component(\'field\', { value: user.address.street, error: errors.address.street|default(null) }) }}',
666+
];
667+
668+
yield 'nested_jsx_components_with_complex_blocks' => [
669+
'<Tabs>
670+
<Tab title="First">
671+
<Panel>
672+
<Block name="header">Title</Block>
673+
<Block name="body">Content</Block>
674+
</Panel>
675+
</Tab>
676+
</Tabs>',
677+
"{% component 'tabs' %}
678+
{% block content %}{% component 'tab' with { title: 'First' } %}
679+
{% block content %}{% component 'panel' %}
680+
{% block header %}Title{% endblock %}
681+
{% block body %}Content{% endblock %}
682+
{% endcomponent %}
683+
{% endblock %}{% endcomponent %}
684+
{% endblock %}{% endcomponent %}",
685+
];
686+
687+
yield 'jsx_component_with_aria_and_data_attributes' => [
688+
'<Button aria-pressed="false" data-analytics-id="login-btn">Login</Button>',
689+
'{% component \'button\' with { \'aria-pressed\': \'false\', \'data-analytics-id\': \'login-btn\' } %}{% block content %}Login{% endblock %}{% endcomponent %}',
690+
];
691+
692+
yield 'jsx_component_with_twig_filters' => [
693+
'<Alert :message="error|trans|capitalize" />',
694+
'{{ component(\'alert\', { message: error|trans|capitalize }) }}',
695+
];
696+
697+
yield 'jsx_component_with_twig_macros' => [
698+
'<Card>{% import "macros.twig" as forms %}<Block name="body">{{ forms.input("username") }}</Block></Card>',
699+
'{% component \'card\' %}{% block content %}{% import "macros.twig" as forms %}{% endblock %}{% block body %}{{ forms.input("username") }}{% endblock %}{% endcomponent %}',
700+
];
701+
702+
yield 'jsx_component_with_mixed_content' => [
703+
'<Notice>This is <strong>important</strong> and <em>urgent</em>.</Notice>',
704+
'{% component \'notice\' %}{% block content %}This is <strong>important</strong> and <em>urgent</em>.{% endblock %}{% endcomponent %}',
705+
];
706+
707+
yield 'jsx_component_with_short_namespace' => [
708+
'<X:Y />',
709+
'{{ component(\'x:Y\') }}',
710+
];
711+
712+
yield 'jsx_component_with_namespaced_attributes' => [
713+
'<Svg xmlns:xlink="http://www.w3.org/1999/xlink" />',
714+
'{{ component(\'svg\', { \'xmlns:xlink\': \'http://www.w3.org/1999/xlink\' }) }}',
715+
];
716+
717+
yield 'jsx_component_with_block_expression' => [
718+
'<Card><Block name="{{ showHeader ? \'header\' : \'footer\' }}">Content</Block></Card>',
719+
"{% component 'card' %}{% block (showHeader ? 'header' %}Content{% endblock %}{% endcomponent %}",
720+
];
721+
}
440722
}

0 commit comments

Comments
 (0)
Please sign in to comment.