Skip to content

[TwigComponent] new twig_component tag and slot #873

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

Closed
10 changes: 10 additions & 0 deletions src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function __construct(
) {
}

// should be deprecated, and use metadataForTwigComponent instead
public function metadataFor(string $name): ComponentMetadata
{
if (!$config = $this->config[$name] ?? null) {
Expand All @@ -45,6 +46,15 @@ public function metadataFor(string $name): ComponentMetadata
return new ComponentMetadata($config);
}

public function metadataForTwigComponent(string $name): ?ComponentMetadata
{
if (!$config = $this->config[$name] ?? null) {
return null;
Copy link
Member

Choose a reason for hiding this comment

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

You want to return null here now instead of an exception? Is that for the Antonio’ anonymous components?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes exactly! If we don't find a corresponding component then we try to render it as anonymous

Copy link
Member

Choose a reason for hiding this comment

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

What about the ability to distinguish attributes from properties for anon components as discussed here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I missed your comment. If you look at the test I used |default to reproduce the behavior we discussed. But maybe I can add this tag in a follow-up PR if this one gets merged.

}

return new ComponentMetadata($config);
}

/**
* Creates the component and "mounts" it with the passed data.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\UX\TwigComponent\DependencyInjection;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -74,6 +75,10 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
->addTag('twig.extension')
->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
->setArguments([
new Reference(ContainerInterface::class),
new Reference('twig'),
])
;

$container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
Expand Down
6 changes: 4 additions & 2 deletions src/TwigComponent/src/Twig/ComponentExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
Expand All @@ -26,7 +27,7 @@
*/
final class ComponentExtension extends AbstractExtension implements ServiceSubscriberInterface
{
public function __construct(private ContainerInterface $container)
public function __construct(private ContainerInterface $container, private Environment $environment)
{
}

Expand All @@ -48,7 +49,8 @@ public function getFunctions(): array
public function getTokenParsers(): array
{
return [
new ComponentTokenParser(fn () => $this->container->get(ComponentFactory::class)),
new ComponentTokenParser(fn () => $this->container->get(ComponentFactory::class), $this->environment),
new SlotTokenParser(),
];
}

Expand Down
80 changes: 74 additions & 6 deletions src/TwigComponent/src/Twig/ComponentNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@

namespace Symfony\UX\TwigComponent\Twig;

use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Twig\Compiler;
use Twig\Node\EmbedNode;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Node;

/**
* @author Fabien Potencier <fabien@symfony.com>
Expand All @@ -23,17 +26,87 @@
*/
final class ComponentNode extends EmbedNode
{
public function __construct(string $component, string $template, int $index, AbstractExpression $variables, bool $only, int $lineno, string $tag)
public function __construct(string $component, string $template, int $index, AbstractExpression $variables, bool $only, int $lineno, string $tag, Node $slot, ?ComponentMetadata $componentMetadata)
{
parent::__construct($template, $index, $variables, $only, false, $lineno, $tag);

$this->setAttribute('component', $component);
$this->setAttribute('componentMetadata', $componentMetadata);

$this->setNode('slot', $slot);
}

public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);

$compiler
->write('$slotsStack = $slotsStack ?? [];'.\PHP_EOL)
;

if ($this->getAttribute('componentMetadata') instanceof ComponentMetadata) {
$this->addComponentProps($compiler);
}

$compiler
->write('ob_start();'.\PHP_EOL)
->subcompile($this->getNode('slot'))
->write('$slot = ob_get_clean();'.\PHP_EOL)
;

$this->addGetTemplate($compiler);

$compiler->raw('->display(');

$this->addTemplateArguments($compiler);
$compiler->raw(");\n");
}

protected function addTemplateArguments(Compiler $compiler)
{
$compiler
->indent(1)
->write("\n")
->write("array_merge(\n")
;

if ($this->getAttribute('componentMetadata') instanceof ComponentMetadata) {
$compiler->write('$props,'.\PHP_EOL);
}

$compiler
->write('$context,[')
->write("'slot' => new ".ComponentSlot::class." (\$slot),\n")
->write("'slots' => \$slotsStack,")
;

if (!$this->getAttribute('componentMetadata') instanceof ComponentMetadata) {
$compiler->write("'attributes' => new ".ComponentAttributes::class.'(');

if ($this->hasNode('variables')) {
$compiler->subcompile($this->getNode('variables'));
} else {
$compiler->raw('[]');
}

$compiler->write(")\n");
}

$compiler
->indent(-1)
->write('],');

if ($this->hasNode('variables')) {
$compiler->subcompile($this->getNode('variables'));
} else {
$compiler->raw('[]');
}

$compiler->write(")\n");
}

private function addComponentProps(Compiler $compiler)
{
$compiler
->raw('$props = $this->extensions[')
->string(ComponentExtension::class)
Expand All @@ -46,10 +119,5 @@ public function compile(Compiler $compiler): void
->raw($this->getAttribute('only') ? '[]' : '$context')
->raw(");\n")
;

$this->addGetTemplate($compiler);

$compiler->raw('->display($props);');
$compiler->raw("\n");
}
}
69 changes: 69 additions & 0 deletions src/TwigComponent/src/Twig/ComponentSlot.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?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\Twig;

use Symfony\UX\TwigComponent\ComponentAttributes;

/**
* thanks to @giorgiopogliani!
* This file is inspired by: https://github.com/giorgiopogliani/twig-components.
*
* @author Mathéo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
class ComponentSlot
{
public ComponentAttributes $attributes;

protected string $contents;

public function __construct(string $contents = '', array $attributes = [])
{
$this->contents = $contents;

$this->withAttributes($attributes);
}

public function withAttributes(array $attributes): self
{
$this->attributes = new ComponentAttributes($attributes);

return $this;
}

public function withContext(array $contexts): void
{
$content = $this->contents;

foreach ($contexts as $key => $value) {
$content = str_replace("<slot_value name=\"$key\"/>", $value, $content);
}

$this->contents = $content;
}

public function toHtml(): string
{
return $this->contents;
}

public function isEmpty(): bool
{
return '' === $this->contents;
}

public function __toString()
{
return $this->toHtml();
}
}
52 changes: 48 additions & 4 deletions src/TwigComponent/src/Twig/ComponentTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
namespace Symfony\UX\TwigComponent\Twig;

use Symfony\UX\TwigComponent\ComponentFactory;
use Twig\Environment;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\NameExpression;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
Expand All @@ -31,28 +33,31 @@ final class ComponentTokenParser extends AbstractTokenParser
/** @var ComponentFactory|callable():ComponentFactory */
private $factory;

private Environment $environment;

/**
* @param callable():ComponentFactory $factory
*/
public function __construct(callable $factory)
public function __construct(callable $factory, Environment $environment)
{
$this->factory = $factory;
$this->environment = $environment;
}

public function parse(Token $token): Node
{
$stream = $this->parser->getStream();
$parent = $this->parser->getExpressionParser()->parseExpression();
$componentName = $this->componentName($parent);
$componentMetadata = $this->factory()->metadataFor($componentName);
$componentMetadata = $this->factory()->metadataForTwigComponent($componentName);

[$variables, $only] = $this->parseArguments();

if (null === $variables) {
$variables = new ArrayExpression([], $parent->getTemplateLine());
}

$parentToken = new Token(Token::STRING_TYPE, $componentMetadata->getTemplate(), $token->getLine());
$parentToken = new Token(Token::STRING_TYPE, $this->getTemplatePath($componentName), $token->getLine());
$fakeParentToken = new Token(Token::STRING_TYPE, '__parent__', $token->getLine());

// inject a fake parent to make the parent() function work
Expand All @@ -65,6 +70,8 @@ public function parse(Token $token): Node

$module = $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true);

$slot = $this->getSlotFromBlockContent($module);

// override the parent with the correct one
if ($fakeParentToken === $parentToken) {
$module->setNode('parent', $parent);
Expand All @@ -74,7 +81,7 @@ public function parse(Token $token): Node

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

return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $token->getLine(), $this->getTag());
return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $token->getLine(), $this->getTag(), $slot, $componentMetadata);
}

public function getTag(): string
Expand All @@ -95,6 +102,34 @@ private function componentName(AbstractExpression $expression): string
throw new \LogicException('Could not parse component name.');
}

private function getTemplatePath(string $name): string
{
$loader = $this->environment->getLoader();
$componentPath = rtrim(str_replace('.', '/', $name));

if (($componentMetadata = $this->factory->metadataForTwigComponent($name)) !== null) {
return $componentMetadata->getTemplate();
}

if ($loader->exists($componentPath)) {
return $componentPath;
}

if ($loader->exists($componentPath.'.html.twig')) {
return $componentPath.'.html.twig';
}

if ($loader->exists('components/'.$componentPath)) {
return 'components/'.$componentPath;
}

if ($loader->exists('/components/'.$componentPath.'.html.twig')) {
return '/components/'.$componentPath.'.html.twig';
}

throw new \LogicException("No template found for: {$name}");
}

private function factory(): ComponentFactory
{
if (\is_callable($this->factory)) {
Expand Down Expand Up @@ -124,4 +159,13 @@ private function parseArguments(): array

return [$variables, $only];
}

private function getSlotFromBlockContent(ModuleNode $module): Node
{
if ($module->getNode('blocks')->hasNode('content')) {
return $module->getNode('blocks')->getNode('content')->getNode(0)->getNode('body');
}

return new Node();
}
}
Loading