Skip to content
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

[TwigComponent] Add new ComponentReflection to extract properties from TwigComponent #2498

Closed
wants to merge 9 commits into from
4 changes: 4 additions & 0 deletions src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 2.23.0

- Add `ComponentPropertiesExtractor` to extract component properties from a Twig component (experimental)

## 2.20.0

- Add Anonymous Component support for 3rd-party bundles #2019
Expand Down
92 changes: 12 additions & 80 deletions src/TwigComponent/src/Command/TwigComponentDebugCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,29 @@
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;

#[AsCommand(name: 'debug:twig-component', description: 'Display components and them usages for an application')]
class TwigComponentDebugCommand extends Command
{
private readonly string $anonymousDirectory;
private readonly ComponentPropertiesExtractor $componentPropertiesExtractor;

public function __construct(
private string $twigTemplatesPath,
private ComponentFactory $componentFactory,
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);
smnandre marked this conversation as resolved.
Show resolved Hide resolved
}

protected function configure(): void
Expand Down Expand Up @@ -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,
));
Comment on lines +218 to +221
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
$propertiesAsArrayOfStrings = array_filter(array_map(
fn (array $property) => $property['display'],
$properties,
));
$properties = array_column($properties, 'display');

1/3


// Anonymous Component
if ($metadata->isAnonymous()) {
$table->addRows([
['Type', '<comment>Anonymous</comment>'],
new TableSeparator(),
['Properties', implode("\n", $this->getAnonymousComponentProperties($metadata))],
['Properties', implode("\n", $propertiesAsArrayOfStrings)],
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
['Properties', implode("\n", $propertiesAsArrayOfStrings)],
['Properties', implode("\n", $properties)],

2/3

]);
$table->render();

Expand All @@ -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)],
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
['Properties', implode("\n", $propertiesAsArrayOfStrings)],
['Properties', implode("\n", $properties)],

3/3

]);

$logMethod = function (\ReflectionMethod $m) {
Expand Down Expand Up @@ -280,80 +288,4 @@ private function displayComponentsTable(SymfonyStyle $io, array $components): vo
}
$table->render();
}

/**
* @return array<string, string>
*/
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<string, string>
*/
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;
}
}
150 changes: 150 additions & 0 deletions src/TwigComponent/src/ComponentPropertiesExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;

/**
* @author Jean-François Lépine <[email protected]>
smnandre marked this conversation as resolved.
Show resolved Hide resolved
*
* @experimental This class is not covered by the BC promise yet
*/
final class ComponentPropertiesExtractor
{
public function __construct(
private readonly Environment $twig,
) {
}

/**
* Returns a list of properties from a Component.
*
* Warning: We do not recommend using this method at runtime, as it is rather slow.
*
* @return array<string, array{display: string, name: string, type: string, default: mixed}>
smnandre marked this conversation as resolved.
Show resolved Hide resolved
*/
public function getComponentProperties(ComponentMetadata $metadata): array
{
if ($metadata->isAnonymous()) {
return $this->getAnonymousComponentProperties($metadata);
}

return $this->getNonAnonymousComponentProperties($metadata);
}

/**
* @return array<string, array{display: string, name: string, type: string, default: mixed}>
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @return array<string, array{display: string, name: string, type: string, default: mixed}>
* @return array<string, array{display: string, name: string, type: string, default: ?string}>

(once fixed the value json encoding)

*/
private function getNonAnonymousComponentProperties(ComponentMetadata $metadata): array
Copy link
Member

Choose a reason for hiding this comment

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

Let's avoid double negation if possible. Anonymous component are a special type of components.

But "non anonymous components" are just "components".

{
$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
) : '');
Comment on lines +64 to +66
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
$propertyDisplay = $typeName.' $'.$propertyName.(null !== $value ? ' = '.json_encode(
$value
) : '');
$propertyDisplay = $typeName.' $'.$propertyName.(null !== $value ? ' = '.json_encode($value) : '');

$properties[$property->name] = [
'name' => $propertyName,
'display' => $propertyDisplay,
'type' => $typeName,
'default' => $value,
Copy link
Member

Choose a reason for hiding this comment

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

Should be json encoded as the other one

];
}

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<array{display: string, name: string, type: string, default: mixed}>
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @return array<array{display: string, name: string, type: string, default: mixed}>
* @return array<array{display: string, name: string, type: string, default: ?string}>

*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -134,13 +135,19 @@ 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'),
new Reference('ux.twig_component.component_factory'),
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')
;
Expand Down
Loading
Loading