diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php index 0e05bd7bff0..9910b0a0425 100644 --- a/src/LiveComponent/tests/Fixtures/Kernel.php +++ b/src/LiveComponent/tests/Fixtures/Kernel.php @@ -26,6 +26,7 @@ use Symfony\UX\TwigComponent\TwigComponentBundle; use Twig\Environment; use Zenstruck\Foundry\ZenstruckFoundryBundle; +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; /** * @author Kevin Bond @@ -112,6 +113,8 @@ protected function configureContainer(ContainerConfigurator $c): void ->set(MoneyNormalizer::class)->autoconfigure()->autowire() ->set(Entity2Normalizer::class)->autoconfigure()->autowire() ->load(__NAMESPACE__.'\\Component\\', __DIR__.'/Component') + ->set(TestingDeterministicIdTwigExtension::class) + ->args([service('ux.live_component.deterministic_id_calculator')]) ; } diff --git a/src/LiveComponent/tests/Fixtures/TestingDeterministicIdTwigExtension.php b/src/LiveComponent/tests/Fixtures/TestingDeterministicIdTwigExtension.php new file mode 100644 index 00000000000..f0d5b2fa89a --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/TestingDeterministicIdTwigExtension.php @@ -0,0 +1,26 @@ +deterministicIdCalculator->calculateDeterministicId(); + } +} diff --git a/src/LiveComponent/tests/Integration/Twig/DeterministicTwigIdCalculatorTest.php b/src/LiveComponent/tests/Integration/Twig/DeterministicTwigIdCalculatorTest.php index b74f4e6c6a2..c014e346089 100644 --- a/src/LiveComponent/tests/Integration/Twig/DeterministicTwigIdCalculatorTest.php +++ b/src/LiveComponent/tests/Integration/Twig/DeterministicTwigIdCalculatorTest.php @@ -14,35 +14,15 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator; use Twig\Environment; -use Twig\Extension\AbstractExtension; -use Twig\TwigFunction; final class DeterministicTwigIdCalculatorTest extends KernelTestCase { public function testReturnsDeterministicId(): void { - $deterministicIdCalculator = new DeterministicTwigIdCalculator(); - $twigExtension = new class($deterministicIdCalculator) extends AbstractExtension { - public function __construct(private DeterministicTwigIdCalculator $deterministicIdCalculator) - { - } - - public function getFunctions(): array - { - return [ - new TwigFunction('get_id_for_test', [$this, 'getIdForTest']), - ]; - } - - public function getIdForTest(): string - { - return $this->deterministicIdCalculator->calculateDeterministicId(); - } - }; - /** @var Environment $twig */ $twig = self::getContainer()->get('twig'); - $twig->addExtension($twigExtension); + /** @var DeterministicTwigIdCalculator $deterministicIdCalculator */ + $deterministicIdCalculator = self::getContainer()->get('ux.live_component.deterministic_id_calculator'); $rendered = $twig->render('deterministic_id.html.twig'); $this->assertStringContainsString('Deterministic Id Line1-1: "live-3860148629-0"', $rendered); diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 65d3eb213b6..e002093fe87 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.8.0 +- Add new HTML syntax for rendering components: `` - `true` attribute values now render just the attribute name, `false` excludes it entirely. - The first argument to `AsTwigComponent` is now optional and defaults to the class name. diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 3ac42aabd3e..6a3820c5752 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -24,6 +24,8 @@ use Symfony\UX\TwigComponent\ComponentStack; use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass; use Symfony\UX\TwigComponent\Twig\ComponentExtension; +use Symfony\UX\TwigComponent\Twig\ComponentLexer; +use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator; /** * @author Kevin Bond @@ -73,5 +75,11 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in % ->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']) ; + + $container->register('ux.twig_component.twig.lexer', ComponentLexer::class); + + $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')]); } } diff --git a/src/TwigComponent/src/Twig/ComponentLexer.php b/src/TwigComponent/src/Twig/ComponentLexer.php new file mode 100644 index 00000000000..5a2b6f875c8 --- /dev/null +++ b/src/TwigComponent/src/Twig/ComponentLexer.php @@ -0,0 +1,42 @@ + + * + * 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 Twig\Lexer; +use Twig\Source; +use Twig\TokenStream; + +/** + * @author Mathèo Daninos + * + * @internal + * + * thanks to @giorgiopogliani for the inspiration on this lexer <3 + * + * @see https://github.com/giorgiopogliani/twig-components + */ +class ComponentLexer extends Lexer +{ + public function tokenize(Source $source): TokenStream + { + $preLexer = new TwigPreLexer(); + $preparsed = $preLexer->preLexComponents($source->getCode()); + + return parent::tokenize( + new Source( + $preparsed, + $source->getName(), + $source->getPath() + ) + ); + } +} diff --git a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php new file mode 100644 index 00000000000..02d8c0fadbd --- /dev/null +++ b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php @@ -0,0 +1,33 @@ + + * + * 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\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator; +use Twig\Environment; + +class TwigEnvironmentConfigurator +{ + private EnvironmentConfigurator $decorated; + + public function __construct( + EnvironmentConfigurator $decorated + ) { + $this->decorated = $decorated; + } + + public function configure(Environment $environment): void + { + $this->decorated->configure($environment); + + $environment->setLexer(new ComponentLexer($environment)); + } +} diff --git a/src/TwigComponent/src/Twig/TwigPreLexer.php b/src/TwigComponent/src/Twig/TwigPreLexer.php new file mode 100644 index 00000000000..12df6c61388 --- /dev/null +++ b/src/TwigComponent/src/Twig/TwigPreLexer.php @@ -0,0 +1,316 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Twig; + +/** + * Rewrites syntaxes to {% component %} syntaxes. + */ +class TwigPreLexer +{ + private string $input; + private int $length; + private int $position = 0; + private int $line; + /** @var array */ + private array $currentComponents = []; + + public function __construct(int $startingLine = 1) + { + $this->line = $startingLine; + } + + public function preLexComponents(string $input): string + { + $this->input = $input; + $this->length = \strlen($input); + $output = ''; + + while ($this->position < $this->length) { + if ($this->consume('consumeComponentName(); + + if ('block' === $componentName) { + // if we're already inside the "default" block, let's close it + if (!empty($this->currentComponents) && $this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock']) { + $output .= '{% endblock %}'; + + $this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] = false; + } + + $output .= $this->consumeBlock(); + + continue; + } + + $attributes = $this->consumeAttributes(); + $isSelfClosing = $this->consume('/>'); + if (!$isSelfClosing) { + $this->consume('>'); + $this->currentComponents[] = ['name' => $componentName, 'hasDefaultBlock' => false]; + } + + $output .= "{% component {$componentName}".($attributes ? " with { {$attributes} }" : '').' %}'; + if ($isSelfClosing) { + $output .= '{% endcomponent %}'; + } + + continue; + } + + if (!empty($this->currentComponents) && $this->check('consume('consumeComponentName(); + $this->consume('>'); + + $lastComponent = array_pop($this->currentComponents); + $lastComponentName = $lastComponent['name']; + + if ($closingComponentName !== $lastComponentName) { + throw new \RuntimeException("Expected closing tag '' but found '' at line {$this->line}"); + } + + // we've reached the end of this component. If we're inside the + // default block, let's close it + if ($lastComponent['hasDefaultBlock']) { + $output .= '{% endblock %}'; + } + + $output .= '{% endcomponent %}'; + + continue; + } + + $char = $this->consumeChar(); + if ("\n" === $char) { + ++$this->line; + } + + // handle adding a default block if needed + if (!empty($this->currentComponents) + && !$this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] + && preg_match('/\S/', $char) + ) { + $this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] = true; + $output .= '{% block content %}'; + } + + $output .= $char; + } + + if (!empty($this->currentComponents)) { + $lastComponent = array_pop($this->currentComponents)['name']; + throw new \RuntimeException(sprintf('Expected closing tag "" not found at line %d.', $lastComponent, $this->line)); + } + + return $output; + } + + private function consumeComponentName(): string + { + $start = $this->position; + while ($this->position < $this->length && preg_match('/[A-Za-z0-9_]/', $this->input[$this->position])) { + ++$this->position; + } + $componentName = substr($this->input, $start, $this->position - $start); + + if (empty($componentName)) { + throw new \RuntimeException("Expected component name at line {$this->line}"); + } + + return $componentName; + } + + private function consumeAttributes(): string + { + $attributes = []; + + while ($this->position < $this->length && !$this->check('>') && !$this->check('/>')) { + $this->consumeWhitespace(); + if ($this->check('>') || $this->check('/>')) { + break; + } + + $isAttributeDynamic = false; + + // :someProp="dynamicVar" + if ($this->check(':')) { + $this->consume(':'); + $isAttributeDynamic = true; + } + + $key = $this->consumeComponentName(); + + // -> someProp: true + if (!$this->check('=')) { + $attributes[] = sprintf('%s: true', $key); + $this->consumeWhitespace(); + continue; + } + + $this->expectAndConsumeChar('='); + $quote = $this->consumeChar(["'", '"']); + + // someProp="{{ dynamicVar }}" + if ($this->consume('{{')) { + $this->consumeWhitespace(); + $attributeValue = rtrim($this->consumeUntil('}')); + $this->expectAndConsumeChar('}'); + $this->expectAndConsumeChar('}'); + $this->consumeUntil($quote); + $isAttributeDynamic = true; + } else { + $attributeValue = $this->consumeUntil($quote); + } + $this->expectAndConsumeChar($quote); + + if ($isAttributeDynamic) { + $attributes[] = sprintf('%s: %s', $key, $attributeValue); + } else { + $attributes[] = sprintf("%s: '%s'", $key, str_replace("'", "\'", $attributeValue)); + } + + $this->consumeWhitespace(); + } + + return implode(', ', $attributes); + } + + private function consume(string $string): bool + { + if (substr($this->input, $this->position, \strlen($string)) === $string) { + $this->position += \strlen($string); + + return true; + } + + return false; + } + + private function consumeChar($validChars = null): string + { + if ($this->position >= $this->length) { + throw new \RuntimeException('Unexpected end of input'); + } + + $char = $this->input[$this->position]; + + if (null !== $validChars && !\in_array($char, (array) $validChars, true)) { + throw new \RuntimeException('Expected one of ['.implode('', (array) $validChars)."] but found '{$char}' at line {$this->line}"); + } + + ++$this->position; + + return $char; + } + + private function consumeUntil(string $endString): string + { + $start = $this->position; + $endCharLength = \strlen($endString); + + while ($this->position < $this->length) { + if (substr($this->input, $this->position, $endCharLength) === $endString) { + break; + } + + if ("\n" === $this->input[$this->position]) { + ++$this->line; + } + ++$this->position; + } + + return substr($this->input, $start, $this->position - $start); + } + + private function consumeWhitespace(): void + { + while ($this->position < $this->length && preg_match('/\s/', $this->input[$this->position])) { + if ("\n" === $this->input[$this->position]) { + ++$this->line; + } + ++$this->position; + } + } + + /** + * Checks that the next character is the one given and consumes it. + */ + private function expectAndConsumeChar(string $char): void + { + if (1 !== \strlen($char)) { + throw new \InvalidArgumentException('Expected a single character'); + } + + if ($this->position >= $this->length || $this->input[$this->position] !== $char) { + throw new \RuntimeException("Expected '{$char}' but found '{$this->input[$this->position]}' at line {$this->line}"); + } + ++$this->position; + } + + private function check(string $chars): bool + { + $charsLength = \strlen($chars); + if ($this->position + $charsLength > $this->length) { + return false; + } + + for ($i = 0; $i < $charsLength; ++$i) { + if ($this->input[$this->position + $i] !== $chars[$i]) { + return false; + } + } + + return true; + } + + private function consumeBlock(): string + { + $attributes = $this->consumeAttributes(); + $this->consume('>'); + + $blockName = ''; + foreach (explode(', ', $attributes) as $attr) { + [$key, $value] = explode(': ', $attr); + if ('name' === $key) { + $blockName = trim($value, "'"); + break; + } + } + + if (empty($blockName)) { + throw new \RuntimeException("Expected block name at line {$this->line}"); + } + + $output = "{% block {$blockName} %}"; + + $closingTag = ''; + if (!$this->doesStringEventuallyExist($closingTag)) { + throw new \RuntimeException("Expected closing tag '{$closingTag}' for block '{$blockName}' at line {$this->line}"); + } + $blockContents = $this->consumeUntil($closingTag); + + $subLexer = new self($this->line); + $output .= $subLexer->preLexComponents($blockContents); + + $this->consume($closingTag); + $output .= '{% endblock %}'; + + return $output; + } + + private function doesStringEventuallyExist(string $needle): bool + { + $remainingString = substr($this->input, $this->position); + + return str_contains($remainingString, $needle); + } +} diff --git a/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig b/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig new file mode 100644 index 00000000000..fcce917f9db --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig @@ -0,0 +1,8 @@ + + custom th ({{ parent() }}) + custom td ({{ parent() }}) + + + My footer + + \ No newline at end of file diff --git a/src/TwigComponent/tests/Fixtures/templates/tags/open_tag.html.twig b/src/TwigComponent/tests/Fixtures/templates/tags/open_tag.html.twig new file mode 100644 index 00000000000..10ef0db20c9 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/tags/open_tag.html.twig @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/TwigComponent/tests/Fixtures/templates/tags/self_close_tag.html.twig b/src/TwigComponent/tests/Fixtures/templates/tags/self_close_tag.html.twig new file mode 100644 index 00000000000..b351e02ea87 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/tags/self_close_tag.html.twig @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TwigComponent/tests/Integration/ComponentLexerTest.php b/src/TwigComponent/tests/Integration/ComponentLexerTest.php new file mode 100644 index 00000000000..7ed6c6c15c7 --- /dev/null +++ b/src/TwigComponent/tests/Integration/ComponentLexerTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Twig\Environment; + +/** + * @author Mathèo Daninos + * + * @internal + */ +class ComponentLexerTest extends KernelTestCase +{ + public function testComponentSyntaxOpenTags(): void + { + $output = self::getContainer()->get(Environment::class)->render('tags/open_tag.html.twig'); + + $this->assertStringContainsString('propA: 1', $output); + $this->assertStringContainsString('propB: hello', $output); + } + + public function testComponentSyntaxSelfCloseTags(): void + { + $output = self::getContainer()->get(Environment::class)->render('tags/self_close_tag.html.twig'); + + $this->assertStringContainsString('propA: 1', $output); + $this->assertStringContainsString('propB: hello', $output); + } + + public function testComponentSyntaxCanRenderEmbeddedComponent(): void + { + $output = self::getContainer()->get(Environment::class)->render('tags/embedded_component.html.twig'); + + $this->assertStringContainsString('data table', $output); + $this->assertStringContainsString('custom th (key)', $output); + $this->assertStringContainsString('custom td (1)', $output); + } +} diff --git a/src/TwigComponent/tests/Unit/TwigPreLexerTest.php b/src/TwigComponent/tests/Unit/TwigPreLexerTest.php new file mode 100644 index 00000000000..d2681cc1618 --- /dev/null +++ b/src/TwigComponent/tests/Unit/TwigPreLexerTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Unit; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\TwigComponent\Twig\TwigPreLexer; + +final class TwigPreLexerTest extends TestCase +{ + /** + * @dataProvider getLexTests + */ + public function testPreLex(string $input, string $expectedOutput): void + { + $lexer = new TwigPreLexer(); + $this->assertSame($expectedOutput, $lexer->preLexComponents($input)); + } + + public function getLexTests(): iterable + { + yield 'simple_component' => [ + '', + '{% component foo %}{% endcomponent %}', + ]; + + yield 'component_with_attributes' => [ + '', + "{% component foo with { bar: 'baz', with_quotes: 'It\'s with quotes' } %}{% endcomponent %}", + ]; + + yield 'component_with_dynamic_attributes' => [ + '', + '{% component foo with { dynamic: dynamicVar, otherDynamic: anotherVar } %}{% endcomponent %}', + ]; + + yield 'component_with_closing_tag' => [ + '', + '{% component foo %}{% endcomponent %}', + ]; + + yield 'component_with_block' => [ + 'Foo', + '{% component foo %}{% block foo_block %}Foo{% endblock %}{% endcomponent %}', + ]; + + yield 'component_with_embedded_component_inside_block' => [ + '', + '{% component foo %}{% block foo_block %}{% component bar %}{% endcomponent %}{% endblock %}{% endcomponent %}', + ]; + + yield 'attribute_with_no_value' => [ + '', + '{% component foo with { bar: true } %}{% endcomponent %}', + ]; + + yield 'component_with_default_block_content' => [ + 'Foo', + '{% component foo %}{% block content %}Foo{% endblock %}{% endcomponent %}', + ]; + + yield 'component_with_default_block_that_holds_a_component_and_multi_blocks' => [ + 'Foo Other block', + '{% component foo %}{% block content %}Foo {% component bar %}{% endcomponent %}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}', + ]; + } +}