Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce a modern, JSX-like syntax for TwigComponents #2662

Open
wants to merge 1 commit into
base: 2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Acme:Button type="primary">
Click me
</Acme:Button>

This is equivalent to:

.. code-block:: html+twig

<twig:Acme:Button type="primary">
Click me
</twig:Acme:Button>

To enable this feature, add the following configuration:

.. code-block:: yaml

# config/packages/twig_component.yaml
twig_component:
short_tags: true

Contributing
------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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 <twig: prefix is optional)')
->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()
Expand Down
9 changes: 8 additions & 1 deletion src/TwigComponent/src/Twig/ComponentLexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -39,4 +41,9 @@ public function tokenize(Source $source): TokenStream
)
);
}

public function enabledShortTags(): void
{
$this->withShortTags = true;
}
}
18 changes: 17 additions & 1 deletion src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
*/
class TwigEnvironmentConfigurator
{
private bool $withShortTags = false;

public function __construct(
private readonly EnvironmentConfigurator $decorated,
) {
Expand All @@ -31,12 +33,26 @@ 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']);
} elseif ($environment->hasExtension(EscaperExtension::class)) {
$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;
}
}
25 changes: 21 additions & 4 deletions src/TwigComponent/src/Twig/TwigPreLexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Twig\Lexer;

/**
* Rewrites <twig:component> syntaxes to {% component %} syntaxes.
* Rewrites <twig:component> or <Component> syntaxes to {% component %} syntaxes.
*/
class TwigPreLexer
{
Expand All @@ -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, '<twig:')) {
// tag may be:
// - prefixed: <twig:componentName>
// - short (jsx like): <ComponentName> (with a capital letter)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// - short (jsx like): <ComponentName> (with a capital letter)
// - short (JSX-like): <ComponentName> (with a capital first letter)


$isPrefixedTags = str_contains($input, '<twig:');
$isShortTags = $this->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));
Comment on lines +50 to +51
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead, can we use a single array_reduce to loop over $componentNames only 1 time instead of 3 times?


// To simplify things in the rest of the class, we replace the component name with twig:<componentName>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// To simplify things in the rest of the class, we replace the component name with twig:<componentName>
// To simplify things in the rest of the class, we replace the component name with <twig:componentName>

foreach ($componentNames as $componentName) {
Comment on lines +50 to +54
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe better, 1 loop instead of 4?

Suggested change
$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:<componentName>
foreach ($componentNames as $componentName) {
// To simplify things in the rest of the class, we replace the component name with twig:<componentName>
foreach ($matches as $match) {
if (!isset($componentName = $match[1])) {
continue;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be even easier in the other way.... not sure we need the 2step PATH

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, changing the code might actually make it slower. Your solution helps reduce iterations if all the nodes are unique.

But if a page contains the same node multiple times, it will trigger multiple preg_replace calls - which are much more expensive than a simple array iteration.

I think it's better to first reduce the array to a unique list, then perform the minimal number of preg_replace calls.

That said, happy to discuss it of course!

$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 = '';
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading