Skip to content
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
20 changes: 20 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,26 @@ There is also a non-HTML syntax that can be used:
{% block footer %}... footer content{% endblock %}
{% endcomponent %}

The ``{% component %}`` tag also accepts dynamic expressions:

.. code-block:: twig

{% set prefix = 'DynamicNameComponent' %}
{% for i in 1..2 %}
{% component (prefix ~ i) %}{% endcomponent %}
{% endfor %}

If you pass a variable that contains a component object, its class will be
used to resolve the component name:

.. code-block:: twig

{% component componentObject %}{% endcomponent %}

Where ``componentObject`` must be an instance of a class-based component (e.g.
``#[AsTwigComponent]``/``#[AsLiveComponent]``). Anonymous components cannot be
resolved from an object expression.

.. _embedded-components-context:

Context / Variables Inside of Blocks
Expand Down
5 changes: 5 additions & 0 deletions src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ public function get(string $name): object
return $this->components->get($metadata->getName());
}

public function isComponentClass(string $class): bool
{
return isset($this->classMap[$class]);
}

private function mount(object $component, array &$data, ComponentMetadata $componentMetadata): void
{
if ($component instanceof AnonymousComponent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) {
->setArguments([
new Reference('ux.twig_component.component_renderer'),
new ServiceLocatorArgument(new TaggedIteratorArgument('ux.twig_component.twig_renderer', indexAttribute: 'key', needsIndexes: true)),
new Reference('ux.twig_component.component_factory'),
])
->addTag('twig.runtime')
;
Expand Down
68 changes: 63 additions & 5 deletions src/TwigComponent/src/Twig/ComponentNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Environment;
use Twig\Error\SyntaxError;
use Twig\Extension\CoreExtension;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\NameExpression;
use Twig\Node\Node;
use Twig\Node\NodeOutputInterface;
use Twig\Template;
Expand All @@ -30,9 +32,9 @@
#[YieldReady]
final class ComponentNode extends Node implements NodeOutputInterface
{
public function __construct(string $component, string $embeddedTemplateName, int $embeddedTemplateIndex, ?AbstractExpression $props, bool $only, int $lineno)
public function __construct(AbstractExpression $component, string $embeddedTemplateName, int $embeddedTemplateIndex, ?AbstractExpression $props, bool $only, int $lineno)
{
$nodes = [];
$nodes = ['component' => $component];
if (null !== $props) {
$nodes['props'] = $props;
}
Expand All @@ -42,7 +44,6 @@ public function __construct(string $component, string $embeddedTemplateName, int
$this->setAttribute('only', $only);
$this->setAttribute('embedded_template', $embeddedTemplateName);
$this->setAttribute('embedded_index', $embeddedTemplateIndex);
$this->setAttribute('component', $component);
}

public function compile(Compiler $compiler): void
Expand All @@ -65,6 +66,63 @@ public function compile(Compiler $compiler): void
->string(ComponentRuntime::class)
->raw(");\n");

$componentNameValue = $compiler->getVarName();
$componentName = $compiler->getVarName();

$compiler
->write(\sprintf('$%s = ', $componentNameValue))
;

if ($this->getNode('component') instanceof NameExpression) {
$name = $this->getNode('component')->getAttribute('name');
$compiler->raw(\sprintf('\\array_key_exists(%s, $context) ? $context[%s] : %s', var_export($name, true), var_export($name, true), var_export($name, true)));
} else {
$compiler->subcompile($this->getNode('component'));
}

$compiler->raw(";\n");

$compiler
->write(\sprintf('if (\\is_object($%s)) {', $componentNameValue))
->raw("\n")
->indent()
->write(\sprintf('if (!$%s->isObjectComponent($%s)) {', $componentRuntime, $componentNameValue))
->raw("\n")
->indent()
->write('throw new ')
->raw('\\'.SyntaxError::class)
->raw('(sprintf(')
->string('The component expression passed to "{%% component %%}" must evaluate to a component name (string/scalar/Stringable) or a component object. Got object "%s".')
->raw(', ')
->raw(\sprintf('$%s::class', $componentNameValue))
->raw('), ')
->repr($this->getTemplateLine())
->raw(", \$this->getSourceContext());\n")
->outdent()
->write("}\n")
->write(\sprintf('$%s = $%s::class;', $componentName, $componentNameValue))
->raw("\n")
->outdent()
->write(\sprintf('} elseif (\\is_scalar($%s) || $%s instanceof \\Stringable) {', $componentNameValue, $componentNameValue))
->raw("\n")
->indent()
->write(\sprintf('$%s = (string) $%s;', $componentName, $componentNameValue))
->raw("\n")
->outdent()
->write("} else {\n")
->indent()
->write('throw new ')
->raw('\\'.SyntaxError::class)
->raw('(sprintf(')
->string('The component expression passed to "{%% component %%}" must evaluate to a component name (string/scalar/Stringable) or a component object. Got "%s".')
->raw(', \\get_debug_type(')
->raw(\sprintf('$%s', $componentNameValue))
->raw(')), ')
->repr($this->getTemplateLine())
->raw(", \$this->getSourceContext());\n")
->outdent()
->write("}\n");

/*
* Block 1) PreCreateForRender handling
*
Expand All @@ -73,7 +131,7 @@ public function compile(Compiler $compiler): void
*/
$compiler
->write(\sprintf('$preRendered = $%s->preRender(', $componentRuntime))
->string($this->getAttribute('component'))
->raw(\sprintf('$%s', $componentName))
->raw(', ')
->raw($twig_to_array)
->raw('(');
Expand Down Expand Up @@ -105,7 +163,7 @@ public function compile(Compiler $compiler): void
*/
$compiler
->write(\sprintf('$preRenderEvent = $%s->startEmbedComponent(', $componentRuntime))
->string($this->getAttribute('component'))
->raw(\sprintf('$%s', $componentName))
->raw(', ')
->raw($twig_to_array)
->raw('(');
Expand Down
7 changes: 7 additions & 0 deletions src/TwigComponent/src/Twig/ComponentRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\UX\TwigComponent\Twig;

use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;

Expand All @@ -26,6 +27,7 @@ final class ComponentRuntime
public function __construct(
private readonly ComponentRenderer $renderer,
private readonly ServiceLocator $renderers,
private readonly ComponentFactory $componentFactory,
) {
}

Expand Down Expand Up @@ -59,4 +61,9 @@ public function startEmbedComponent(string $name, array $props, array $context,
{
return $this->renderer->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
}

public function isObjectComponent(object $component): bool
{
return $this->componentFactory->isComponentClass($component::class);
}
}
20 changes: 12 additions & 8 deletions src/TwigComponent/src/Twig/ComponentTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ public function parse(Token $token): Node

if (method_exists($this->parser, 'parseExpression')) {
// Since Twig 3.21
$componentName = $this->componentName($this->parser->parseExpression());
$componentNameExpression = $this->componentNameExpression($this->parser->parseExpression());
} else {
$componentName = $this->componentName($this->parser->getExpressionParser()->parseExpression());
$componentNameExpression = $this->componentNameExpression($this->parser->getExpressionParser()->parseExpression());
}

if (null === $componentName) {
if (null === $componentNameExpression) {
throw new SyntaxError('Could not parse component name.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
}

Expand Down Expand Up @@ -78,25 +78,29 @@ public function parse(Token $token): Node

$stream->expect(Token::BLOCK_END_TYPE);

return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $propsExpression, $only, $token->getLine());
return new ComponentNode($componentNameExpression, $module->getTemplateName(), $module->getAttribute('index'), $propsExpression, $only, $token->getLine());
}

public function getTag(): string
{
return 'component';
}

private function componentName(AbstractExpression $expression): ?string
private function componentNameExpression(AbstractExpression $expression): ?AbstractExpression
{
if ($expression instanceof ConstantExpression) { // using {% component 'name' %}
return $expression->getAttribute('value');
return $expression;
}

if ($expression instanceof NameExpression) { // using {% component name %}
return $expression->getAttribute('name');
return $expression;
}

return null;
if ($expression instanceof ArrayExpression) {
return null;
}

return $expression;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent('DynamicNameComponent1', template: 'components/dynamic_name_component_1.html.twig')]
final class DynamicNameComponent1
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent('DynamicNameComponent2', template: 'components/dynamic_name_component_2.html.twig')]
final class DynamicNameComponent2
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DynamicNameComponent1 rendered
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DynamicNameComponent2 rendered
50 changes: 50 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@

use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\DynamicNameComponent1;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\DynamicNameComponent2;
use Symfony\UX\TwigComponent\Tests\Fixtures\User;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand Down Expand Up @@ -157,6 +160,53 @@ public function testCanRenderEmbeddedComponent()
$this->assertStringContainsString('custom td (1)', $output);
}

public function testCanRenderEmbeddedComponentWithDynamicNameBuiltInLoop()
{
$environment = self::getContainer()->get(Environment::class);
$template = $environment->createTemplate('{% set prefix = "DynamicNameComponent" %}{% for i in 1..2 %}{% component (prefix ~ i) %}{% endcomponent %}{% endfor %}');

$output = $template->render();

$this->assertStringContainsString('DynamicNameComponent1 rendered', $output);
$this->assertStringContainsString('DynamicNameComponent2 rendered', $output);
}

public function testThrowsWhenDynamicComponentNameDoesNotExist()
{
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('Unknown component "DynamicNameComponent3".');

$environment = self::getContainer()->get(Environment::class);
$template = $environment->createTemplate('{% set prefix = "DynamicNameComponent" %}{% component (prefix ~ 3) %}{% endcomponent %}');
$template->render();
}

public function testCanRenderEmbeddedComponentFromObjectExpression()
{
$environment = self::getContainer()->get(Environment::class);
$template = $environment->createTemplate('{% for component in components %}{% component component %}{% endcomponent %}{% endfor %}');
$output = $template->render([
'components' => [
new DynamicNameComponent1(),
new DynamicNameComponent2(),
],
]);

$this->assertStringContainsString('DynamicNameComponent1 rendered', $output);
$this->assertStringContainsString('DynamicNameComponent2 rendered', $output);
}

public function testThrowsWhenObjectExpressionIsNotAComponent()
{
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('must evaluate to a component name (string/scalar/Stringable) or a component object');
$this->expectExceptionMessage('stdClass');

$environment = self::getContainer()->get(Environment::class);
$template = $environment->createTemplate('{% component obj %}{% endcomponent %}');
$template->render(['obj' => new \stdClass()]);
}

public function testComponentWithNamespace()
{
$output = $this->renderComponent('foo:bar:baz');
Expand Down
10 changes: 10 additions & 0 deletions src/TwigComponent/tests/Integration/Twig/ComponentParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ public function testItThrowsWhenComponentNameCannotBeParsed()
$environment->createTemplate($source, 'foo.html.twig');
}

public function testItAcceptsDynamicComponentExpression()
{
$environment = $this->createEnvironment();
$source = '{% component var.componentName %}{% endcomponent %}';

$template = $environment->createTemplate($source);

$this->assertInstanceOf(TemplateWrapper::class, $template);
}

public static function provideValidComponentNames(): iterable
{
// Those names are all syntactically valid even if
Expand Down
Loading