diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md
index 34d110e3b36..51733a1dd66 100644
--- a/src/TwigComponent/CHANGELOG.md
+++ b/src/TwigComponent/CHANGELOG.md
@@ -1,5 +1,9 @@
# CHANGELOG
+## 2.24.0
+
+- Introduce an experimental Short tags system for TwigComponents, making `twig:` prefix optional #2662
+
## 2.20.0
- Add Anonymous Component support for 3rd-party bundles #2019
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index de3cb4e242e..90d595be8c1 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -1719,6 +1719,36 @@ Pass the name of some component as an argument to print its details:
| | int $min = 10 |
+---------------------------------------------------+-----------------------------------+
+Short Tags
+----------
+
+An experimental new short tag system, allowing the omission of the 'twig:' prefix in HTML tags, was introduced in version 2.24.
+
+This mode allows you to omit the `twig:` prefix and reference components directly by their name,
+with the first letter capitalized.
+
+.. code-block:: html+twig
+
+
+ Click me
+
+
+This is equivalent to:
+
+.. code-block:: html+twig
+
+
+ Click me
+
+
+To enable this feature, add the following configuration:
+
+.. code-block:: yaml
+
+ # config/packages/twig_component.yaml
+ twig_component:
+ short_tags: true
+
Contributing
------------
diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
index ebe2bb1753b..9eb4c3a7980 100644
--- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
+++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
@@ -129,11 +129,25 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) {
;
$container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
+ if (true === $config['short_tags']) {
+ $container->getDefinition('ux.twig_component.twig.lexer')
+ ->addMethodCall('enableShortTags');
+ }
$container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class)
->setDecoratedService(new Reference('twig.configurator.environment'))
->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]);
+ // Currently, the ComponentLexer is not injected into the TwigEnvironmentConfigurator, but built directly in the
+ // code (with a new ComponentLexer($environment)).
+ // We cannot change this behavior without a major refactoring : environment is currently configured at runtime.
+ // So we add setters for our required options
+ // This should be improved in the future: currently, parameters of the ComponentLexer are not injectables.
+ if (true === $config['short_tags']) {
+ $container->getDefinition('ux.twig_component.twig.environment_configurator')
+ ->addMethodCall('enabledShortTags');
+ }
+
$container->register('ux.twig_component.command.debug', TwigComponentDebugCommand::class)
->setArguments([
new Parameter('twig.default_path'),
@@ -217,6 +231,10 @@ public function getConfigTreeBuilder(): TreeBuilder
->info('Enables the profiler for Twig Component (in debug mode)')
->defaultValue('%kernel.debug%')
->end()
+ ->booleanNode('short_tags')
+ ->info('Enables the short syntax for Twig Components (the defaultValue(false)
+ ->end()
->scalarNode('controllers_json')
->setDeprecated('symfony/ux-twig-component', '2.18', 'The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0.')
->defaultNull()
diff --git a/src/TwigComponent/src/Twig/ComponentLexer.php b/src/TwigComponent/src/Twig/ComponentLexer.php
index 5a2b6f875c8..c088d7b1aa2 100644
--- a/src/TwigComponent/src/Twig/ComponentLexer.php
+++ b/src/TwigComponent/src/Twig/ComponentLexer.php
@@ -26,9 +26,11 @@
*/
class ComponentLexer extends Lexer
{
+ private bool $withShortTags = false;
+
public function tokenize(Source $source): TokenStream
{
- $preLexer = new TwigPreLexer();
+ $preLexer = new TwigPreLexer(withShortTags: $this->withShortTags);
$preparsed = $preLexer->preLexComponents($source->getCode());
return parent::tokenize(
@@ -39,4 +41,9 @@ public function tokenize(Source $source): TokenStream
)
);
}
+
+ public function enabledShortTags(): void
+ {
+ $this->withShortTags = true;
+ }
}
diff --git a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
index 4b952a10047..af3ef6105aa 100644
--- a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
+++ b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
@@ -22,6 +22,8 @@
*/
class TwigEnvironmentConfigurator
{
+ private bool $withShortTags = false;
+
public function __construct(
private readonly EnvironmentConfigurator $decorated,
) {
@@ -31,7 +33,13 @@ public function configure(Environment $environment): void
{
$this->decorated->configure($environment);
- $environment->setLexer(new ComponentLexer($environment));
+ $componentLexer = new ComponentLexer($environment);
+
+ if ($this->withShortTags) {
+ $componentLexer->enabledShortTags();
+ }
+
+ $environment->setLexer($componentLexer);
if (class_exists(EscaperRuntime::class)) {
$environment->getRuntime(EscaperRuntime::class)->addSafeClass(ComponentAttributes::class, ['html']);
@@ -39,4 +47,12 @@ public function configure(Environment $environment): void
$environment->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
}
}
+
+ /**
+ * This method should be replaced by a proper autowiring configuration.
+ */
+ public function enabledShortTags(): void
+ {
+ $this->withShortTags = true;
+ }
}
diff --git a/src/TwigComponent/src/Twig/TwigPreLexer.php b/src/TwigComponent/src/Twig/TwigPreLexer.php
index 3cdee464e67..b2a9c5f6e4f 100644
--- a/src/TwigComponent/src/Twig/TwigPreLexer.php
+++ b/src/TwigComponent/src/Twig/TwigPreLexer.php
@@ -15,7 +15,7 @@
use Twig\Lexer;
/**
- * Rewrites syntaxes to {% component %} syntaxes.
+ * Rewrites or syntaxes to {% component %} syntaxes.
*/
class TwigPreLexer
{
@@ -28,17 +28,34 @@ class TwigPreLexer
*/
private array $currentComponents = [];
- public function __construct(int $startingLine = 1)
+ public function __construct(int $startingLine = 1, private readonly bool $withShortTags = false)
{
$this->line = $startingLine;
}
public function preLexComponents(string $input): string
{
- if (!str_contains($input, '
+ // - short (jsx like): (with a capital letter)
+
+ $isPrefixedTags = str_contains($input, 'withShortTags && preg_match_all('/<([A-Z][a-zA-Z0-9_:-]+)([^>]*)>/', $input, $matches, \PREG_SET_ORDER);
+
+ if (!$isPrefixedTags && !$isShortTags) {
return $input;
}
+ if ($isShortTags) {
+ $componentNames = array_map(fn ($match) => $match[1], $matches);
+ $componentNames = array_unique(array_filter($componentNames));
+
+ // To simplify things in the rest of the class, we replace the component name with twig:
+ foreach ($componentNames as $componentName) {
+ $input = preg_replace('!<(/?)'.preg_quote($componentName).'!', '<$1twig:'.lcfirst($componentName), $input);
+ }
+ }
+
$this->input = $input = str_replace(["\r\n", "\r"], "\n", $input);
$this->length = \strlen($input);
$output = '';
@@ -394,7 +411,7 @@ private function consumeBlock(string $componentName): string
}
$blockContents = $this->consumeUntilEndBlock();
- $subLexer = new self($this->line);
+ $subLexer = new self($this->line, $this->withShortTags);
$output .= $subLexer->preLexComponents($blockContents);
$this->consume($closingTag);
diff --git a/src/TwigComponent/tests/Unit/TwigPreLexerTest.php b/src/TwigComponent/tests/Unit/TwigPreLexerTest.php
index 2878fb3bc74..04451c52149 100644
--- a/src/TwigComponent/tests/Unit/TwigPreLexerTest.php
+++ b/src/TwigComponent/tests/Unit/TwigPreLexerTest.php
@@ -26,6 +26,15 @@ public function testPreLex(string $input, string $expectedOutput): void
$this->assertSame($expectedOutput, $lexer->preLexComponents($input));
}
+ /**
+ * @dataProvider getLexTestsWhithShortOptions
+ */
+ public function testPreLexWithShortTags(string $input, string $expectedOutput): void
+ {
+ $lexer = new TwigPreLexer(withShortTags: true);
+ $this->assertSame($expectedOutput, $lexer->preLexComponents($input));
+ }
+
/**
* @dataProvider getInvalidSyntaxTests
*/
@@ -376,6 +385,12 @@ public static function getLexTests(): iterable
'content',
'{% component \'foobar\' with { bar: \'baz\', ...attr } %}{% block content %}content{% endblock %}{% endcomponent %}',
];
+
+ yield 'jsx_component_simple_component_not_enabled_by_default' => [
+ '',
+ '',
+ ];
+
yield 'component_with_comment_line' => [
"",
'{{ component(\'foo\') }}',
@@ -437,4 +452,271 @@ public static function getLexTests(): iterable
TWIG,
];
}
+
+ public static function getLexTestsWhithShortOptions()
+ {
+ yield 'not_a_component' => [
+ '',
+ '',
+ ];
+
+ yield 'jsx_component_simple_component' => [
+ '',
+ '{{ component(\'foo\') }}',
+ ];
+
+ yield 'jsx_component_attribute_with_no_value_and_no_attributes' => [
+ '',
+ '{{ component(\'foo\') }}',
+ ];
+
+ yield 'jsx_component_with_default_block_content' => [
+ 'Foo',
+ '{% component \'foo\' %}{% block content %}Foo{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_default_block_that_holds_a_component_and_multi_blocks' => [
+ 'Foo Other block',
+ '{% component \'foo\' %}{% block content %}Foo {{ component(\'bar\') }}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_character_:_on_his_name' => [
+ '',
+ '{% component \'foo:bar\' %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_character_-_on_his_name' => [
+ '',
+ '{% component \'foo-bar\' %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_character_._on_his_name' => [
+ '',
+ '{% component \'foo.bar\' %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_block' => [
+ '
+
+ xxxx
+
+ ',
+ '{% component \'successAlert\' %}
+ {% block alert_message %}
+ xxxx
+ {% endblock %}
+ {% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_sub_blocks' => [
+ '
+
+
+
+
+
+
+ ',
+ '{% component \'successAlert\' %}
+ {% block content %}{% component \'message\' with { name: \'alert_message\' } %}
+ {% block content %}{{ component(\'icon\', { name: \'success\' }) }}
+ {% endblock %}{% endcomponent %}
+ {% component \'message\' with { name: \'alert_message\' } %}
+ {% block content %}{{ component(\'icon\', { name: \'success\' }) }}
+ {% endblock %}{% endcomponent %}
+ {% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_multiple_nested_namespaces' => [
+ '',
+ '{% component \'foo:Bar:Baz\' %}{% endcomponent %}',
+ ];
+
+ yield 'mixing_standard_and_jsx_components' => [
+ 'Click me',
+ "{% component 'alert' %}{% block content %}{% component 'Button' %}{% block content %}Click me{% endblock %}{% endcomponent %}{% endblock %}{% endcomponent %}",
+ ];
+
+ yield 'jsx_component_with_dynamic_attributes' => [
+ '',
+ '{% component \'alert\' with { level: alertLevel, title: (title) } %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_spreading' => [
+ '',
+ '{% component \'button\' with { ...buttonAttrs } %}{% block content %}Click me{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_named_blocks' => [
+ 'TitleContent',
+ '{% component \'card\' %}{% block header %}Title{% endblock %}{% block body %}Content{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'nested_jsx_components_with_namespaces' => [
+ 'MenuPage',
+ "{% component 'uI:Layout' %}{% block content %}{% component 'uI:Sidebar' %}{% block content %}Menu{% endblock %}{% endcomponent %}{% component 'uI:Content' %}{% block content %}Page{% endblock %}{% endcomponent %}{% endblock %}{% endcomponent %}",
+ ];
+
+ yield 'normal_html_tags_not_transformed' => [
+ 'Text
',
+ 'Text
',
+ ];
+
+ yield 'lowercase_component_name_not_transformed' => [
+ 'Content',
+ 'Content',
+ ];
+
+ yield 'jsx_component_with_special_characters_in_attributes' => [
+ '',
+ '{% component \'button\' with { \'data-testid\': \'test-btn\', \'aria-label\': \'Click me\' } %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_html_comments' => [
+ 'Alert title',
+ '{% component \'alert\' %}{% block content %}{% endblock %}{% block title %}Alert title{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_boolean_attributes' => [
+ '',
+ '{% component \'button\' with { disabled: true, primary: true } %}{% block content %}Click me{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_whitespace' => [
+ '',
+ '{% component \'button\' with { type: \'primary\', size: \'large\' } %}
+ {% block content %}Submit
+ {% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_numbers_in_name' => [
+ 'Content',
+ '{% component \'grid3x3\' %}{% block content %}Content{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_complex_expressions' => [
+ '',
+ '{{ component(\'dataTable\', { items: items|filter(item => item.active)|sort((a, b) => a.name <=> b.name) }) }}',
+ ];
+
+ // Looks like HTML 5 custom elements
+ yield 'jsx_component_similar_to_custom_element' => [
+ 'Content',
+ "{% component 'custom-Element' with { 'data-value': 'test' } %}{% block content %}Content{% endblock %}{% endcomponent %}",
+ ];
+
+ yield 'nested_jsx_components_with_same_name' => [
+ '',
+ '{% component \'section\' %}{% block content %}{% component \'section\' %}{% block content %}Nested{% endblock %}{% endcomponent %}{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_embedded_twig_conditions' => [
+ '{% if showTitle %}Title{% endif %}',
+ "{% component 'card' %}{% block content %}{% if showTitle %}{% endblock %}{% block title %}Title{% endblock %}{% block content %}{% endif %}{% endblock %}{% endcomponent %}",
+ ];
+
+ yield 'jsx_component_with_embedded_twig_loops' => [
+ '{% for item in items %}- {{ item.name }}
{% endfor %}
',
+ '{% component \'list\' %}{% block content %}{% for item in items %}{% component \'item\' with { value: item } %}{% block content %}{{ item.name }}{% endblock %}{% endcomponent %}{% endfor %}{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_escaped_attribute_values' => [
+ '',
+ "{{ component('alert', { message: 'This is a \'quoted\' message' }) }}",
+ ];
+
+ yield 'jsx_component_with_self_closing_html_in_content' => [
+ '
',
+ '{% component \'card\' %}{% block content %}
{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_array_and_object_attributes' => [
+ '',
+ '{{ component(\'select\', { options: [\'option1\', \'option2\'], config: { multiselect: true } }) }}',
+ ];
+
+ yield 'jsx_component_interpolation_inside_dynamic_attribute' => [
+ '',
+ "{{ component('button', { class: isActive ? 'active-{{ theme }}' : 'inactive' }) }}",
+ ];
+
+ yield 'jsx_component_with_mixed_case_name' => [
+ 'Content',
+ '{% component \'dataTable\' with { sorting: \'asc\' } %}{% block content %}Content{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_namespace_and_mixed_case' => [
+ '',
+ '{{ component(\'app:UserProfile:Avatar\', { size: \'medium\' }) }}',
+ ];
+
+ yield 'jsx_component_with_complex_twig_in_attributes' => [
+ '',
+ '{{ component(\'form\', { errors: form.errors is defined ? form.errors : {}, disabled: form.isSubmitting ?? false }) }}',
+ ];
+
+ yield 'jsx_component_with_path_expression_in_attributes' => [
+ '',
+ '{{ component(\'field\', { value: user.address.street, error: errors.address.street|default(null) }) }}',
+ ];
+
+ yield 'nested_jsx_components_with_complex_blocks' => [
+ '
+
+
+ Title
+ Content
+
+
+ ',
+ "{% component 'tabs' %}
+ {% block content %}{% component 'tab' with { title: 'First' } %}
+ {% block content %}{% component 'panel' %}
+ {% block header %}Title{% endblock %}
+ {% block body %}Content{% endblock %}
+ {% endcomponent %}
+ {% endblock %}{% endcomponent %}
+ {% endblock %}{% endcomponent %}",
+ ];
+
+ yield 'jsx_component_with_aria_and_data_attributes' => [
+ '',
+ '{% component \'button\' with { \'aria-pressed\': \'false\', \'data-analytics-id\': \'login-btn\' } %}{% block content %}Login{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_twig_filters' => [
+ '',
+ '{{ component(\'alert\', { message: error|trans|capitalize }) }}',
+ ];
+
+ yield 'jsx_component_with_twig_macros' => [
+ '{% import "macros.twig" as forms %}{{ forms.input("username") }}',
+ '{% component \'card\' %}{% block content %}{% import "macros.twig" as forms %}{% endblock %}{% block body %}{{ forms.input("username") }}{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_mixed_content' => [
+ 'This is important and urgent.',
+ '{% component \'notice\' %}{% block content %}This is important and urgent.{% endblock %}{% endcomponent %}',
+ ];
+
+ yield 'jsx_component_with_short_namespace' => [
+ '',
+ '{{ component(\'x:Y\') }}',
+ ];
+
+ yield 'jsx_component_with_namespaced_attributes' => [
+ '',
+ '{{ component(\'svg\', { \'xmlns:xlink\': \'http://www.w3.org/1999/xlink\' }) }}',
+ ];
+
+ yield 'jsx_component_with_block_expression' => [
+ 'Content',
+ "{% component 'card' %}{% block (showHeader ? 'header' %}Content{% endblock %}{% endcomponent %}",
+ ];
+ }
}