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' => [ + '
Nested
', + '{% 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' => [ + '