From 567d81a0caed8387ed01ae9dd1dd924ec6474eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20L=C3=A9pine?= Date: Fri, 10 Jan 2025 11:02:28 +0100 Subject: [PATCH] Add new ComponentPropertiesExtractor to extract properties from TwigComponent --- src/TwigComponent/CHANGELOG.md | 4 + .../src/Command/TwigComponentDebugCommand.php | 92 ++---------- .../src/ComponentPropertiesExtractor.php | 141 ++++++++++++++++++ .../TwigComponentExtension.php | 7 + .../Unit/ComponentPropertiesExtractorTest.php | 106 +++++++++++++ 5 files changed, 270 insertions(+), 80 deletions(-) create mode 100644 src/TwigComponent/src/ComponentPropertiesExtractor.php create mode 100644 src/TwigComponent/tests/Unit/ComponentPropertiesExtractorTest.php diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 34d110e3b36..fd4fd592ef5 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.23.0 + +- Add `ComponentPropertiesExtractor` to extract component properties from a Twig component + ## 2.20.0 - Add Anonymous Component support for 3rd-party bundles #2019 diff --git a/src/TwigComponent/src/Command/TwigComponentDebugCommand.php b/src/TwigComponent/src/Command/TwigComponentDebugCommand.php index f883f59ef96..cfb31ad31f2 100644 --- a/src/TwigComponent/src/Command/TwigComponentDebugCommand.php +++ b/src/TwigComponent/src/Command/TwigComponentDebugCommand.php @@ -21,10 +21,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Finder\Finder; -use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentMetadata; -use Symfony\UX\TwigComponent\Twig\PropsNode; +use Symfony\UX\TwigComponent\ComponentPropertiesExtractor; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -32,6 +31,7 @@ class TwigComponentDebugCommand extends Command { private readonly string $anonymousDirectory; + private readonly ComponentPropertiesExtractor $componentPropertiesExtractor; public function __construct( private string $twigTemplatesPath, @@ -39,9 +39,11 @@ public function __construct( private Environment $twig, private readonly array $componentClassMap, ?string $anonymousDirectory = null, + ?ComponentPropertiesExtractor $componentPropertiesExtractor = null, ) { parent::__construct(); $this->anonymousDirectory = $anonymousDirectory ?? 'components'; + $this->componentPropertiesExtractor = $componentPropertiesExtractor ?? new ComponentPropertiesExtractor($this->twig); } protected function configure(): void @@ -212,12 +214,18 @@ private function displayComponentDetails(SymfonyStyle $io, string $name): void ['Template', $metadata->getTemplate()], ]); + $properties = $this->componentPropertiesExtractor->getComponentProperties($metadata); + $propertiesAsArrayOfStrings = array_filter(array_map( + fn (array $property) => $property['display'], + $properties, + )); + // Anonymous Component if ($metadata->isAnonymous()) { $table->addRows([ ['Type', 'Anonymous'], new TableSeparator(), - ['Properties', implode("\n", $this->getAnonymousComponentProperties($metadata))], + ['Properties', implode("\n", $propertiesAsArrayOfStrings)], ]); $table->render(); @@ -229,7 +237,7 @@ private function displayComponentDetails(SymfonyStyle $io, string $name): void new TableSeparator(), // ['Attributes Var', $metadata->get('attributes_var')], ['Public Props', $metadata->isPublicPropsExposed() ? 'Yes' : 'No'], - ['Properties', implode("\n", $this->getComponentProperties($metadata))], + ['Properties', implode("\n", $propertiesAsArrayOfStrings)], ]); $logMethod = function (\ReflectionMethod $m) { @@ -280,80 +288,4 @@ private function displayComponentsTable(SymfonyStyle $io, array $components): vo } $table->render(); } - - /** - * @return array - */ - private function getComponentProperties(ComponentMetadata $metadata): array - { - $properties = []; - $reflectionClass = new \ReflectionClass($metadata->getClass()); - foreach ($reflectionClass->getProperties() as $property) { - $propertyName = $property->getName(); - - if ($metadata->isPublicPropsExposed() && $property->isPublic()) { - $type = $property->getType(); - if ($type instanceof \ReflectionNamedType) { - $typeName = $type->getName(); - } else { - $typeName = (string) $type; - } - $value = $property->getDefaultValue(); - $propertyDisplay = $typeName.' $'.$propertyName.(null !== $value ? ' = '.json_encode($value) : ''); - $properties[$property->name] = $propertyDisplay; - } - - foreach ($property->getAttributes(ExposeInTemplate::class) as $exposeAttribute) { - /** @var ExposeInTemplate $attribute */ - $attribute = $exposeAttribute->newInstance(); - $properties[$property->name] = $attribute->name ?? $property->name; - } - } - - return $properties; - } - - /** - * Extract properties from {% props %} tag in anonymous template. - * - * @return array - */ - private function getAnonymousComponentProperties(ComponentMetadata $metadata): array - { - $source = $this->twig->load($metadata->getTemplate())->getSourceContext(); - $tokenStream = $this->twig->tokenize($source); - $moduleNode = $this->twig->parse($tokenStream); - - $propsNode = null; - foreach ($moduleNode->getNode('body') as $bodyNode) { - foreach ($bodyNode as $node) { - if (PropsNode::class === $node::class) { - $propsNode = $node; - break 2; - } - } - } - if (!$propsNode instanceof PropsNode) { - return []; - } - - $propertyNames = $propsNode->getAttribute('names'); - $properties = array_combine($propertyNames, $propertyNames); - foreach ($propertyNames as $propName) { - if ($propsNode->hasNode($propName) - && ($valueNode = $propsNode->getNode($propName)) - && $valueNode->hasAttribute('value') - ) { - $value = $valueNode->getAttribute('value'); - if (\is_bool($value)) { - $value = $value ? 'true' : 'false'; - } else { - $value = json_encode($value); - } - $properties[$propName] = $propName.' = '.$value; - } - } - - return $properties; - } } diff --git a/src/TwigComponent/src/ComponentPropertiesExtractor.php b/src/TwigComponent/src/ComponentPropertiesExtractor.php new file mode 100644 index 00000000000..4ca5263c17b --- /dev/null +++ b/src/TwigComponent/src/ComponentPropertiesExtractor.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; +use Symfony\UX\TwigComponent\Twig\PropsNode; +use Twig\Environment; + +final class ComponentPropertiesExtractor +{ + public function __construct( + private readonly Environment $twig, + ) { + } + + /** + * @return array + */ + public function getComponentProperties(ComponentMetadata $medata) + { + if ($medata->isAnonymous()) { + return $this->getAnonymousComponentProperties($medata); + } + + return $this->getNonAnonymousComponentProperties($medata); + } + + /** + * @return array + */ + private function getNonAnonymousComponentProperties(ComponentMetadata $metadata): array + { + $properties = []; + $reflectionClass = new \ReflectionClass($metadata->getClass()); + foreach ($reflectionClass->getProperties() as $property) { + $propertyName = $property->getName(); + + if ($metadata->isPublicPropsExposed() && $property->isPublic()) { + $type = $property->getType(); + if ($type instanceof \ReflectionNamedType) { + $typeName = $type->getName(); + } else { + $typeName = (string) $type; + } + $value = $property->getDefaultValue(); + $propertyDisplay = $typeName.' $'.$propertyName.(null !== $value ? ' = '.json_encode( + $value + ) : ''); + $properties[$property->name] = [ + 'name' => $propertyName, + 'display' => $propertyDisplay, + 'type' => $typeName, + 'default' => $value, + ]; + } + + foreach ($property->getAttributes(ExposeInTemplate::class) as $exposeAttribute) { + /** @var ExposeInTemplate $attribute */ + $attribute = $exposeAttribute->newInstance(); + $properties[$property->name] = [ + 'name' => $attribute->name ?? $property->name, + 'display' => $attribute->name ?? $property->name, + 'type' => 'mixed', + 'default' => null, + ]; + } + } + + return $properties; + } + + /** + * Extract properties from {% props %} tag in anonymous template. + * + * @return array + */ + private function getAnonymousComponentProperties(ComponentMetadata $metadata): array + { + $source = $this->twig->load($metadata->getTemplate())->getSourceContext(); + $tokenStream = $this->twig->tokenize($source); + $moduleNode = $this->twig->parse($tokenStream); + + $propsNode = null; + foreach ($moduleNode->getNode('body') as $bodyNode) { + foreach ($bodyNode as $node) { + if (PropsNode::class === $node::class) { + $propsNode = $node; + break 2; + } + } + } + if (!$propsNode instanceof PropsNode) { + return []; + } + + $propertyNames = $propsNode->getAttribute('names'); + $properties = array_combine($propertyNames, $propertyNames); + foreach ($propertyNames as $propName) { + if ($propsNode->hasNode($propName) + && ($valueNode = $propsNode->getNode($propName)) + && $valueNode->hasAttribute('value') + ) { + $value = $valueNode->getAttribute('value'); + if (\is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else { + $value = json_encode($value); + } + $display = $propName.' = '.$value; + $properties[$propName] = [ + 'name' => $propName, + 'display' => $display, + 'type' => \is_bool($value) ? 'bool' : 'mixed', + 'default' => $value, + ]; + } + } + + foreach ($properties as $propertyData) { + if (\is_string($propertyData)) { + $properties[$propertyData] = [ + 'name' => $propertyData, + 'display' => $propertyData, + 'type' => 'mixed', + 'default' => null, + ]; + } + } + + return $properties; + } +} diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index ebe2bb1753b..90b9fc46c2a 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -32,6 +32,7 @@ use Symfony\UX\TwigComponent\Command\TwigComponentDebugCommand; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentProperties; +use Symfony\UX\TwigComponent\ComponentPropertiesExtractor; use Symfony\UX\TwigComponent\ComponentRenderer; use Symfony\UX\TwigComponent\ComponentRendererInterface; use Symfony\UX\TwigComponent\ComponentStack; @@ -134,6 +135,11 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) { ->setDecoratedService(new Reference('twig.configurator.environment')) ->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]); + $container->register('ux.twig_component.extractor_properties', ComponentPropertiesExtractor::class) + ->setArguments([ + new Reference('twig'), + ]); + $container->register('ux.twig_component.command.debug', TwigComponentDebugCommand::class) ->setArguments([ new Parameter('twig.default_path'), @@ -141,6 +147,7 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) { new Reference('twig'), new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), $config['anonymous_template_directory'], + new Reference('ux.twig_component.extractor_properties'), ]) ->addTag('console.command') ; diff --git a/src/TwigComponent/tests/Unit/ComponentPropertiesExtractorTest.php b/src/TwigComponent/tests/Unit/ComponentPropertiesExtractorTest.php new file mode 100644 index 00000000000..4c6b31ca524 --- /dev/null +++ b/src/TwigComponent/tests/Unit/ComponentPropertiesExtractorTest.php @@ -0,0 +1,106 @@ + + * + * 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 Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentPropertiesExtractor; +use Twig\Environment; + +class ComponentPropertiesExtractorTest extends KernelTestCase +{ + public function testPropsAreFoundInTwigComponent(): void + { + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $twig = self::getContainer()->get(Environment::class); + $metadata = $factory->metadataFor('DivComponent5'); + + $extractor = new ComponentPropertiesExtractor($twig); + $attributes = $extractor->getComponentProperties($metadata); + + $this->assertEquals([ + 'divComponentName' => [ + 'display' => 'string $divComponentName = "foo"', + 'name' => 'divComponentName', + 'type' => 'string', + 'default' => 'foo', + ], + ], $attributes); + } + + public function testPropsAreFoundInTwigComponentWithoutProps(): void + { + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $twig = self::getContainer()->get(Environment::class); + $metadata = $factory->metadataFor('DivComponent6'); + + $extractor = new ComponentPropertiesExtractor($twig); + $attributes = $extractor->getComponentProperties($metadata); + + $this->assertEmpty($attributes); + } + + public function testPropsAreFoundInTwigAnonymousComponent(): void + { + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $twig = self::getContainer()->get(Environment::class); + $metadata = $factory->metadataFor('Button'); + + $extractor = new ComponentPropertiesExtractor($twig); + $attributes = $extractor->getComponentProperties($metadata); + + $expected = [ + 'label' => [ + 'display' => 'label', + 'name' => 'label', + 'type' => 'mixed', + 'default' => null, + ], + 'primary' => [ + 'display' => 'primary = true', + 'name' => 'primary', + 'type' => 'mixed', + 'default' => 'true', + ], + ]; + $this->assertEquals($expected, $attributes); + } + + public function testPropsAreFoundInTwigAnonymousComponentWithJusteAttributes(): void + { + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $twig = self::getContainer()->get(Environment::class); + $metadata = $factory->metadataFor('JustAttributes'); + + $extractor = new ComponentPropertiesExtractor($twig); + $attributes = $extractor->getComponentProperties($metadata); + + $this->assertEmpty($attributes); + } + + public function testPropsAreFoundInTwigAnonymousComponentWithEmptyProps(): void + { + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $twig = self::getContainer()->get(Environment::class); + $metadata = $factory->metadataFor('EmptyProps'); + + $extractor = new ComponentPropertiesExtractor($twig); + $attributes = $extractor->getComponentProperties($metadata); + + $this->assertEmpty($attributes); + } +}