Skip to content

Commit d67f8bd

Browse files
committed
add debug:component command
1 parent 9265573 commit d67f8bd

File tree

5 files changed

+359
-0
lines changed

5 files changed

+359
-0
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Add `RenderedComponent::crawler()` and `toString()` methods.
88
- Allow a block outside a Twig component to be available inside via `outerBlocks`.
99
- Fix `<twig:component>` syntax where an attribute is set to an empty value.
10+
- Add component debug command for TwigComponent and LiveComponent.
1011

1112
## 2.9.0
1213

src/TwigComponent/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"twig/twig": "^2.14.7|^3.0.4"
3535
},
3636
"require-dev": {
37+
"symfony/console": "^5.4|^6.0",
3738
"symfony/css-selector": "^5.4|^6.0",
3839
"symfony/dom-crawler": "^5.4|^6.0",
3940
"symfony/framework-bundle": "^5.4|^6.0",
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Helper\Table;
17+
use Symfony\Component\Console\Input\InputArgument;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
use Symfony\Component\Finder\Finder;
22+
use Symfony\UX\TwigComponent\ComponentFactory;
23+
use Symfony\UX\TwigComponent\Twig\PropsNode;
24+
use Twig\Environment;
25+
26+
#[AsCommand(name: 'debug:component', description: 'Display current components and them usages for an application')]
27+
class ComponentDebugCommand extends Command
28+
{
29+
public function __construct(private string $twigTemplatesPath, private ComponentFactory $componentFactory, private Environment $twigEnvironment, private iterable $components)
30+
{
31+
parent::__construct();
32+
}
33+
34+
protected function configure(): void
35+
{
36+
$this
37+
->setDefinition([
38+
new InputArgument('name', InputArgument::OPTIONAL, 'A component name'),
39+
])
40+
->setHelp(<<<'EOF'
41+
The <info>%command.name%</info> display current components and them usages for an application:
42+
43+
<info>php %command.full_name%</info>
44+
45+
EOF
46+
)
47+
;
48+
}
49+
50+
protected function execute(InputInterface $input, OutputInterface $output): int
51+
{
52+
$io = new SymfonyStyle($input, $output);
53+
$name = $input->getArgument('name');
54+
55+
if ($name) {
56+
try {
57+
$metadata = $this->componentFactory->metadataFor($name);
58+
} catch (\Exception $e) {
59+
$io->error($e->getMessage());
60+
61+
return Command::FAILURE;
62+
}
63+
64+
$class = $metadata->get('class');
65+
$allProperties = [];
66+
67+
if ($class) {
68+
$propertyLabel = 'Properties (type / name / default value if exist)';
69+
70+
$reflectionClass = new \ReflectionClass($class);
71+
$properties = $reflectionClass->getProperties();
72+
73+
foreach ($properties as $property) {
74+
if ($property->isPublic()) {
75+
$visibility = $property->getType()?->getName();
76+
$propertyName = $property->getName();
77+
$value = $property->getDefaultValue();
78+
79+
$allProperties = [
80+
...$allProperties,
81+
$visibility.' $'.$propertyName.(null !== $value ? ' = '.$value : ''),
82+
];
83+
}
84+
}
85+
} else {
86+
$propertyLabel = 'Properties (name / default value if exist)';
87+
88+
$source = $this->twigEnvironment->load($metadata->getTemplate())->getSourceContext();
89+
$tokenStream = $this->twigEnvironment->tokenize($source);
90+
$bodyNode = $this->twigEnvironment->parse($tokenStream)->getNode('body')->getNode(0);
91+
92+
$propsNode = [];
93+
94+
foreach ($bodyNode as $node) {
95+
if ($node instanceof PropsNode) {
96+
$propsNode = $node;
97+
break;
98+
}
99+
}
100+
101+
if (\count($propsNode) > 0) {
102+
$allVariables = $propsNode->getAttribute('names');
103+
104+
foreach ($allVariables as $variable) {
105+
if ($propsNode->hasNode($variable)) {
106+
$value = $propsNode->getNode($variable)->getAttribute('value');
107+
108+
if (\is_bool($value)) {
109+
$value = $value ? 'true' : 'false';
110+
}
111+
112+
$property = $variable.' = '.$value;
113+
} else {
114+
$property = $variable;
115+
}
116+
117+
$allProperties = [
118+
...$allProperties,
119+
$property,
120+
];
121+
}
122+
}
123+
}
124+
125+
$componentInfos = [
126+
['Component', $name],
127+
['Class', $class ?? 'Anonymous component'],
128+
['Template', $metadata->getTemplate()],
129+
[$propertyLabel, \count($allProperties) > 0 ? implode("\n", $allProperties) : null],
130+
];
131+
132+
$table = new Table($output);
133+
$table->setHeaders(['Property', 'Value']);
134+
135+
foreach ($componentInfos as $info) {
136+
$table->addRow($info);
137+
}
138+
139+
$table->render();
140+
141+
return Command::SUCCESS;
142+
}
143+
144+
$finderTemplates = new Finder();
145+
$finderTemplates->files()->in("{$this->twigTemplatesPath}/components");
146+
147+
$anonymousTemplatesComponents = [];
148+
foreach ($finderTemplates as $template) {
149+
$anonymousTemplatesComponents[] = $template->getRelativePathname();
150+
}
151+
152+
$componentsWithClass = [];
153+
foreach ($this->components as $class) {
154+
$reflectionClass = new \ReflectionClass($class);
155+
$attributes = $reflectionClass->getAttributes();
156+
157+
foreach ($attributes as $attribute) {
158+
$arguments = $attribute->getArguments();
159+
160+
$name = $arguments['name'] ?? $arguments[0] ?? null;
161+
$template = $arguments['template'] ?? $arguments[1] ?? null;
162+
163+
if (null !== $template || null !== $name) {
164+
if (null !== $template && null !== $name) {
165+
$templateFile = str_replace('components/', '', $template);
166+
$metadata = $this->componentFactory->metadataFor($name);
167+
} elseif (null !== $name) {
168+
$templateFile = str_replace(':', '/', "{$name}.html.twig");
169+
$metadata = $this->componentFactory->metadataFor($name);
170+
} else {
171+
$templateFile = str_replace('components/', '', $template);
172+
$metadata = $this->componentFactory->metadataFor(str_replace('.html.twig', '', $templateFile));
173+
}
174+
} else {
175+
$templateFile = "{$reflectionClass->getShortName()}.html.twig";
176+
$metadata = $this->componentFactory->metadataFor($reflectionClass->getShortName());
177+
}
178+
179+
$componentsWithClass[] = $metadata->getName();
180+
181+
if (($key = array_search($templateFile, $anonymousTemplatesComponents)) !== false) {
182+
unset($anonymousTemplatesComponents[$key]);
183+
}
184+
}
185+
}
186+
187+
$anonymousComponents = array_map(fn ($template): string => str_replace('/', ':', str_replace('.html.twig', '', $template)), $anonymousTemplatesComponents);
188+
189+
$allComponents = array_merge($componentsWithClass, $anonymousComponents);
190+
$dataToRender = [];
191+
foreach ($allComponents as $component) {
192+
$metadata = $this->componentFactory->metadataFor($component);
193+
194+
$dataToRender = [...$dataToRender,
195+
[
196+
$metadata->getName(),
197+
$metadata->get('class') ?? 'Anonymous component',
198+
$metadata->getTemplate(),
199+
],
200+
];
201+
}
202+
203+
$table = new Table($output);
204+
$table->setHeaders(['Component', 'Class', 'Template']);
205+
206+
foreach ($dataToRender as $data) {
207+
$table->addRow($data);
208+
}
209+
210+
$table->render();
211+
212+
return Command::SUCCESS;
213+
}
214+
}

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
use Symfony\Component\DependencyInjection\ContainerBuilder;
1717
use Symfony\Component\DependencyInjection\Exception\LogicException;
1818
use Symfony\Component\DependencyInjection\Extension\Extension;
19+
use Symfony\Component\DependencyInjection\Parameter;
1920
use Symfony\Component\DependencyInjection\Reference;
2021
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
22+
use Symfony\UX\TwigComponent\Command\ComponentDebugCommand;
2123
use Symfony\UX\TwigComponent\ComponentFactory;
2224
use Symfony\UX\TwigComponent\ComponentRenderer;
2325
use Symfony\UX\TwigComponent\ComponentRendererInterface;
@@ -28,6 +30,8 @@
2830
use Symfony\UX\TwigComponent\Twig\ComponentLexer;
2931
use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator;
3032

33+
use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
34+
3135
/**
3236
* @author Kevin Bond <kevinbond@gmail.com>
3337
*
@@ -91,5 +95,15 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
9195
$container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class)
9296
->setDecoratedService(new Reference('twig.configurator.environment'))
9397
->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]);
98+
99+
$container->register('console.command.stimulus_component_debug', ComponentDebugCommand::class)
100+
->setArguments([
101+
new Parameter('twig.default_path'),
102+
new Reference('ux.twig_component.component_factory'),
103+
new Reference('twig'),
104+
tagged_iterator('twig.component'),
105+
])
106+
->addTag('console.command')
107+
;
94108
}
95109
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Unit;
13+
14+
use Symfony\Bundle\FrameworkBundle\Console\Application;
15+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
16+
use Symfony\Component\Console\Tester\CommandTester;
17+
18+
class ComponentDebugCommandTest extends KernelTestCase
19+
{
20+
public function testWithNoComponent(): void
21+
{
22+
$commandTester = $this->createCommandTester();
23+
$commandTester->execute([]);
24+
25+
$commandTester->assertCommandIsSuccessful();
26+
27+
$this->tableDisplayCheck($commandTester->getDisplay());
28+
}
29+
30+
public function testWithNoMatchComponent(): void
31+
{
32+
$commandTester = $this->createCommandTester();
33+
$result = $commandTester->execute(['name' => 'NoMatchComponent']);
34+
35+
$this->assertEquals(1, $result);
36+
$this->assertStringContainsString('Unknown component "NoMatchComponent".', $commandTester->getDisplay());
37+
}
38+
39+
public function testComponentWithClass(): void
40+
{
41+
$commandTester = $this->createCommandTester();
42+
$commandTester->execute(['name' => 'BasicComponent']);
43+
44+
$commandTester->assertCommandIsSuccessful();
45+
46+
$display = $commandTester->getDisplay();
47+
48+
$this->tableDisplayCheck($display);
49+
$this->tableDisplayCheckWithOneComponent($display);
50+
$this->assertStringContainsString('BasicComponent', $display);
51+
$this->assertStringContainsString('Component\BasicComponent', $display);
52+
$this->assertStringContainsString('components/BasicComponent.html.twig', $display);
53+
}
54+
55+
public function testComponentWithClassPropertiesAndCustomName(): void
56+
{
57+
$commandTester = $this->createCommandTester();
58+
$commandTester->execute(['name' => 'component_c']);
59+
60+
$commandTester->assertCommandIsSuccessful();
61+
62+
$display = $commandTester->getDisplay();
63+
64+
$this->tableDisplayCheck($display);
65+
$this->tableDisplayCheckWithOneComponent($display);
66+
$this->assertStringContainsString('component_c', $display);
67+
$this->assertStringContainsString('Component\ComponentC', $display);
68+
$this->assertStringContainsString('components/component_c.html.twig', $display);
69+
$this->assertStringContainsString('$propA', $display);
70+
$this->assertStringContainsString('$propB', $display);
71+
$this->assertStringContainsString('$propC', $display);
72+
}
73+
74+
public function testComponentWithClassPropertiesCustomNameAndCustomTemplate(): void
75+
{
76+
$commandTester = $this->createCommandTester();
77+
$commandTester->execute(['name' => 'component_b']);
78+
79+
$commandTester->assertCommandIsSuccessful();
80+
81+
$display = $commandTester->getDisplay();
82+
83+
$this->tableDisplayCheck($display);
84+
$this->tableDisplayCheckWithOneComponent($display);
85+
$this->assertStringContainsString('component_b', $display);
86+
$this->assertStringContainsString('Component\ComponentB', $display);
87+
$this->assertStringContainsString('components/custom1.html.twig', $display);
88+
$this->assertStringContainsString('string $value', $display);
89+
$this->assertStringContainsString('string $postValue', $display);
90+
}
91+
92+
public function testWithAnonymousComponent(): void
93+
{
94+
$commandTester = $this->createCommandTester();
95+
$commandTester->execute(['name' => 'Button']);
96+
97+
$commandTester->assertCommandIsSuccessful();
98+
99+
$display = $commandTester->getDisplay();
100+
101+
$this->tableDisplayCheck($display);
102+
$this->assertStringContainsString('Button', $display);
103+
$this->assertStringContainsString('Anonymous component', $display);
104+
$this->assertStringContainsString('components/Button.html.twig', $display);
105+
$this->assertStringContainsString('Properties (name / default value if exist)', $display);
106+
$this->assertStringContainsString('label', $display);
107+
$this->assertStringContainsString('primary = true', $display);
108+
}
109+
110+
private function createCommandTester(): CommandTester
111+
{
112+
$kernel = self::bootKernel();
113+
$application = new Application($kernel);
114+
115+
return new CommandTester($application->find('debug:component'));
116+
}
117+
118+
private function tableDisplayCheck(string $display): void
119+
{
120+
$this->assertStringContainsString('Component', $display);
121+
$this->assertStringContainsString('Class', $display);
122+
$this->assertStringContainsString('Template', $display);
123+
}
124+
125+
private function tableDisplayCheckWithOneComponent(string $display): void
126+
{
127+
$this->assertStringContainsString('Properties (type / name / default value if exist)', $display);
128+
}
129+
}

0 commit comments

Comments
 (0)