Skip to content

Commit 0fb0a38

Browse files
committed
[Twig] add embedded components
1 parent 3c0c2c5 commit 0fb0a38

File tree

8 files changed

+215
-2
lines changed

8 files changed

+215
-2
lines changed

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public function onPreRender(PreRenderEvent $event): void
3434
return;
3535
}
3636

37+
if (method_exists($event, 'isEmbedded') && $event->isEmbedded()) {
38+
// TODO: remove method_exists once min ux-twig-component version has this method
39+
throw new \LogicException('Embedded components cannot be live.');
40+
}
41+
3742
$metadata = $event->getMetadata();
3843
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
3944
$variables = $event->getVariables();

src/TwigComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 2.2
44

55
- Allow to pass stringable object as non mapped component attribute
6+
- Add _embedded_ components.
67

78
## 2.1
89

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,14 @@ public function render(MountedComponent $mounted): string
4949
return $this->twig->render($event->getTemplate(), $event->getVariables());
5050
}
5151

52-
private function preRender(MountedComponent $mounted): PreRenderEvent
52+
public function embeddedContext(string $name, array $props, array $context): array
53+
{
54+
$context[PreRenderEvent::EMBEDDED] = true;
55+
56+
return $this->preRender($this->factory->create($name, $props), $context)->getVariables();
57+
}
58+
59+
private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent
5360
{
5461
if (!$this->safeClassesRegistered) {
5562
$this->twig->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
@@ -60,6 +67,9 @@ private function preRender(MountedComponent $mounted): PreRenderEvent
6067
$component = $mounted->getComponent();
6168
$metadata = $this->factory->metadataFor($mounted->getName());
6269
$variables = array_merge(
70+
// first so values can be overridden
71+
$context,
72+
6373
// add the component as "this"
6474
['this' => $component],
6575

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
6767
$container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
6868
->addTag('twig.extension')
6969
->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
70+
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
7071
;
7172
}
7273
}

src/TwigComponent/src/EventListener/PreRenderEvent.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
*/
2323
final class PreRenderEvent extends Event
2424
{
25+
/** @internal */
26+
public const EMBEDDED = '__embedded';
27+
2528
private string $template;
2629

2730
/**
@@ -35,6 +38,11 @@ public function __construct(
3538
$this->template = $this->metadata->getTemplate();
3639
}
3740

41+
public function isEmbedded(): bool
42+
{
43+
return $this->variables[self::EMBEDDED] ?? false;
44+
}
45+
3846
/**
3947
* @return string The twig template used for the component
4048
*/
@@ -48,6 +56,10 @@ public function getTemplate(): string
4856
*/
4957
public function setTemplate(string $template): self
5058
{
59+
if ($this->isEmbedded()) {
60+
throw new \LogicException('Cannot modify template for embedded components.');
61+
}
62+
5163
$this->template = $template;
5264

5365
return $this;

src/TwigComponent/src/Twig/ComponentExtension.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Contracts\Service\ServiceSubscriberInterface;
16+
use Symfony\UX\TwigComponent\ComponentFactory;
1617
use Symfony\UX\TwigComponent\ComponentRenderer;
1718
use Twig\Extension\AbstractExtension;
1819
use Twig\TwigFunction;
@@ -32,7 +33,10 @@ public function __construct(private ContainerInterface $container)
3233

3334
public static function getSubscribedServices(): array
3435
{
35-
return [ComponentRenderer::class];
36+
return [
37+
ComponentRenderer::class,
38+
ComponentFactory::class,
39+
];
3640
}
3741

3842
public function getFunctions(): array
@@ -42,8 +46,20 @@ public function getFunctions(): array
4246
];
4347
}
4448

49+
public function getTokenParsers(): array
50+
{
51+
return [
52+
new ComponentTokenParser(fn () => $this->container->get(ComponentFactory::class)),
53+
];
54+
}
55+
4556
public function render(string $name, array $props = []): string
4657
{
4758
return $this->container->get(ComponentRenderer::class)->createAndRender($name, $props);
4859
}
60+
61+
public function embeddedContext(string $name, array $props, array $context): array
62+
{
63+
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context);
64+
}
4965
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Symfony\UX\TwigComponent\Twig;
4+
5+
use Twig\Compiler;
6+
use Twig\Node\EmbedNode;
7+
use Twig\Node\Expression\ArrayExpression;
8+
9+
/**
10+
* @author Fabien Potencier <fabien@symfony.com>
11+
* @author Kevin Bond <kevinbond@gmail.com>
12+
*
13+
* @experimental
14+
*
15+
* @internal
16+
*/
17+
final class ComponentNode extends EmbedNode
18+
{
19+
public function __construct(string $component, string $template, int $index, ArrayExpression $variables, bool $only, int $lineno, string $tag)
20+
{
21+
parent::__construct($template, $index, $variables, $only, false, $lineno, $tag);
22+
23+
$this->setAttribute('component', $component);
24+
}
25+
26+
public function compile(Compiler $compiler): void
27+
{
28+
$compiler->addDebugInfo($this);
29+
30+
$compiler
31+
->raw('$props = $this->extensions[')
32+
->string(ComponentExtension::class)
33+
->raw(']->embeddedContext(')
34+
->string($this->getAttribute('component'))
35+
->raw(', ')
36+
->raw('twig_to_array(')
37+
->subcompile($this->getNode('variables'))
38+
->raw('), ')
39+
->raw($this->getAttribute('only') ? '[]' : '$context')
40+
->raw(");\n")
41+
;
42+
43+
$this->addGetTemplate($compiler);
44+
45+
$compiler->raw('->display($props);');
46+
$compiler->raw("\n");
47+
}
48+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
namespace Symfony\UX\TwigComponent\Twig;
4+
5+
use Symfony\UX\TwigComponent\ComponentFactory;
6+
use Twig\Node\Expression\AbstractExpression;
7+
use Twig\Node\Expression\ArrayExpression;
8+
use Twig\Node\Expression\ConstantExpression;
9+
use Twig\Node\Expression\NameExpression;
10+
use Twig\Node\Node;
11+
use Twig\Token;
12+
use Twig\TokenParser\AbstractTokenParser;
13+
14+
/**
15+
* @author Fabien Potencier <fabien@symfony.com>
16+
* @author Kevin Bond <kevinbond@gmail.com>
17+
*
18+
* @experimental
19+
*
20+
* @internal
21+
*/
22+
final class ComponentTokenParser extends AbstractTokenParser
23+
{
24+
/** @var ComponentFactory|callable():ComponentFactory */
25+
private $factory;
26+
27+
/**
28+
* @param callable():ComponentFactory $factory
29+
*/
30+
public function __construct(callable $factory)
31+
{
32+
$this->factory = $factory;
33+
}
34+
35+
public function parse(Token $token): Node
36+
{
37+
$stream = $this->parser->getStream();
38+
$parent = $this->parser->getExpressionParser()->parseExpression();
39+
$componentName = $this->componentName($parent);
40+
$componentMetadata = $this->factory()->metadataFor($componentName);
41+
42+
[$variables, $only] = $this->parseArguments();
43+
44+
if (null === $variables) {
45+
$variables = new ArrayExpression([], $parent->getTemplateLine());
46+
}
47+
48+
$parentToken = new Token(Token::STRING_TYPE, $componentMetadata->getTemplate(), $token->getLine());
49+
$fakeParentToken = new Token(Token::STRING_TYPE, '__parent__', $token->getLine());
50+
51+
// inject a fake parent to make the parent() function work
52+
$stream->injectTokens([
53+
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
54+
new Token(Token::NAME_TYPE, 'extends', $token->getLine()),
55+
$parentToken,
56+
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
57+
]);
58+
59+
$module = $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true);
60+
61+
// override the parent with the correct one
62+
if ($fakeParentToken === $parentToken) {
63+
$module->setNode('parent', $parent);
64+
}
65+
66+
$this->parser->embedTemplate($module);
67+
68+
$stream->expect(Token::BLOCK_END_TYPE);
69+
70+
return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $token->getLine(), $this->getTag());
71+
}
72+
73+
public function getTag(): string
74+
{
75+
return 'component';
76+
}
77+
78+
private function componentName(AbstractExpression $expression): string
79+
{
80+
if ($expression instanceof ConstantExpression) { // using {% component 'name' %}
81+
return $expression->getAttribute('value');
82+
}
83+
84+
if ($expression instanceof NameExpression) { // using {% component name %}
85+
return $expression->getAttribute('name');
86+
}
87+
88+
throw new \LogicException('Could not parse component name.');
89+
}
90+
91+
private function factory(): ComponentFactory
92+
{
93+
if (\is_callable($this->factory)) {
94+
$this->factory = ($this->factory)();
95+
}
96+
97+
return $this->factory;
98+
}
99+
100+
private function parseArguments(): array
101+
{
102+
$stream = $this->parser->getStream();
103+
104+
$variables = null;
105+
106+
if ($stream->nextIf(Token::NAME_TYPE, 'with')) {
107+
$variables = $this->parser->getExpressionParser()->parseExpression();
108+
}
109+
110+
$only = false;
111+
112+
if ($stream->nextIf(Token::NAME_TYPE, 'only')) {
113+
$only = true;
114+
}
115+
116+
$stream->expect(Token::BLOCK_END_TYPE);
117+
118+
return [$variables, $only];
119+
}
120+
}

0 commit comments

Comments
 (0)