diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 02b46078539..805c23711f4 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.20.0 + +- Add Anonymous Component support for 3rd-party bundles #2019 + ## 2.17.0 - Add nested attribute support #1405 diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 0d40355e739..d1fa5ee12fc 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -1622,6 +1622,42 @@ controls how components are named and where their templates live: If a component class matches multiple namespaces, the first matched will be used. +3rd-Party Bundle +~~~~~~~~~~~~~~~~ + +The flexibility of Twig Components is extended even further when integrated +with third-party bundles, allowing developers to seamlessly include pre-built +components into their projects. + +Anonymous Components +-------------------- + +.. versionadded:: 2.20 + + The bundle convention for Anonymous components was added in TwigComponents 2.18. + +Using a component from a third-party bundle is just as straightforward as using +one from your own application. Once the bundle is installed and configured, you +can reference its components directly within your Twig templates: + +.. code-block:: html+twig + + + Click me + + +Here, the component name is composed of the bundle's Twig namespace ``Shadcn``, followed +by a colon, and then the component path Button. + +.. note:: + + You can discover the Twig namespace of every registered bundle by inspecting the + ``bin/console debug:twig`` command. + +The component must be located in the bundle's ``templates/components/`` directory. For +example, the component referenced as ```` should have its template +file at ``templates/components/Button.html.twig`` within the Shadcn bundle. + Debugging Components -------------------- @@ -1635,13 +1671,14 @@ that live in ``templates/components/``: $ php bin/console debug:twig-component +---------------+-----------------------------+------------------------------------+------+ - | Component | Class | Template | Live | + | Component | Class | Template | Type | +---------------+-----------------------------+------------------------------------+------+ | Coucou | App\Components\Alert | components/Coucou.html.twig | | - | RandomNumber | App\Components\RandomNumber | components/RandomNumber.html.twig | X | + | RandomNumber | App\Components\RandomNumber | components/RandomNumber.html.twig | Live | | Test | App\Components\foo\Test | components/foo/Test.html.twig | | - | Button | Anonymous component | components/Button.html.twig | | - | foo:Anonymous | Anonymous component | components/foo/Anonymous.html.twig | | + | Button | | components/Button.html.twig | Anon | + | foo:Anonymous | | components/foo/Anonymous.html.twig | Anon | + | Acme:Button | | @Acme/components/Button.html.twig | Anon | +---------------+-----------------------------+------------------------------------+------+ Pass the name of some component as an argument to print its details: @@ -1654,9 +1691,10 @@ Pass the name of some component as an argument to print its details: | Property | Value | +---------------------------------------------------+-----------------------------------+ | Component | RandomNumber | - | Live | X | | Class | App\Components\RandomNumber | | Template | components/RandomNumber.html.twig | + | Type | Live | + +---------------------------------------------------+-----------------------------------+ | Properties (type / name / default value if exist) | string $name = toto | | | string $type = test | | Live Properties | int $max = 1000 | diff --git a/src/TwigComponent/src/Command/TwigComponentDebugCommand.php b/src/TwigComponent/src/Command/TwigComponentDebugCommand.php index f727a223fcf..2ceae41719b 100644 --- a/src/TwigComponent/src/Command/TwigComponentDebugCommand.php +++ b/src/TwigComponent/src/Command/TwigComponentDebugCommand.php @@ -27,6 +27,7 @@ use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\Twig\PropsNode; 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 @@ -148,13 +149,46 @@ private function findComponents(): array */ private function findAnonymousComponents(): array { + $componentsDir = $this->twigTemplatesPath.'/'.$this->anonymousDirectory; + $dirs = [$componentsDir => FilesystemLoader::MAIN_NAMESPACE]; + $twigLoader = $this->twig->getLoader(); + if ($twigLoader instanceof FilesystemLoader) { + foreach ($twigLoader->getNamespaces() as $namespace) { + if (str_starts_with($namespace, '!')) { + continue; // ignore parent convention namespaces + } + + foreach ($twigLoader->getPaths($namespace) as $path) { + if (FilesystemLoader::MAIN_NAMESPACE === $namespace) { + $componentsDir = $path.'/'.$this->anonymousDirectory; + } else { + $componentsDir = $path.'/components'; + } + + if (!is_dir($componentsDir)) { + continue; + } + + $dirs[$componentsDir] = $namespace; + } + } + } + $components = []; - $anonymousPath = $this->twigTemplatesPath.'/'.$this->anonymousDirectory; $finderTemplates = new Finder(); - $finderTemplates->files()->in($anonymousPath)->notPath('/_')->name('*.html.twig'); + $finderTemplates->files() + ->in(array_keys($dirs)) + ->notPath('/_') + ->name('*.html.twig') + ; foreach ($finderTemplates as $template) { $component = str_replace('/', ':', $template->getRelativePathname()); - $component = substr($component, 0, -10); + $component = substr($component, 0, -10); // remove file extension ".html.twig" + + if (isset($dirs[$template->getPath()]) && FilesystemLoader::MAIN_NAMESPACE !== $dirs[$template->getPath()]) { + $component = $dirs[$template->getPath()].':'.$component; + } + $components[$component] = $component; } diff --git a/src/TwigComponent/src/ComponentTemplateFinder.php b/src/TwigComponent/src/ComponentTemplateFinder.php index 10e463caf60..0ceb583e1f3 100644 --- a/src/TwigComponent/src/ComponentTemplateFinder.php +++ b/src/TwigComponent/src/ComponentTemplateFinder.php @@ -66,6 +66,16 @@ public function findAnonymousComponentTemplate(string $name): ?string return $template; } + $parts = explode('/', $componentPath, 2); + if (\count($parts) < 2) { + return null; + } + + $template = '@'.$parts[0].'/components/'.$parts[1].'.html.twig'; + if ($loader->exists($template)) { + return $template; + } + return null; } } diff --git a/src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/AcmeBundle.php b/src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/AcmeBundle.php new file mode 100644 index 00000000000..47a0f05a53e --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/AcmeBundle.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixtures\Bundle\AcmeBundle; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +class AcmeBundle extends Bundle +{ + public function getPath(): string + { + return __DIR__; + } +} diff --git a/src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/templates/components/Button.html.twig b/src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/templates/components/Button.html.twig new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/templates/components/Button.html.twig @@ -0,0 +1 @@ + diff --git a/src/TwigComponent/tests/Fixtures/Kernel.php b/src/TwigComponent/tests/Fixtures/Kernel.php index 934a3d923b5..1bd2d507c33 100644 --- a/src/TwigComponent/tests/Fixtures/Kernel.php +++ b/src/TwigComponent/tests/Fixtures/Kernel.php @@ -17,6 +17,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\UX\TwigComponent\Tests\Fixtures\Bundle\AcmeBundle\AcmeBundle; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB; use Symfony\UX\TwigComponent\TwigComponentBundle; @@ -32,6 +33,7 @@ public function registerBundles(): iterable yield new FrameworkBundle(); yield new TwigBundle(); yield new TwigComponentBundle(); + yield new AcmeBundle(); } protected function configureContainer(ContainerConfigurator $c): void diff --git a/src/TwigComponent/tests/Integration/Command/TwigComponentDebugCommandTest.php b/src/TwigComponent/tests/Integration/Command/TwigComponentDebugCommandTest.php index c869e3b7707..f94b25d182b 100644 --- a/src/TwigComponent/tests/Integration/Command/TwigComponentDebugCommandTest.php +++ b/src/TwigComponent/tests/Integration/Command/TwigComponentDebugCommandTest.php @@ -150,6 +150,21 @@ public function testWithAnonymousComponent(): void $this->assertStringContainsString('primary = true', $display); } + public function testWithBundleAnonymousComponent(): void + { + $commandTester = $this->createCommandTester(); + $commandTester->execute(['name' => 'Acme:Button']); + + $commandTester->assertCommandIsSuccessful(); + + $display = $commandTester->getDisplay(); + + $this->tableDisplayCheck($display); + $this->assertStringContainsString('Acme:Button', $display); + $this->assertStringContainsString('@Acme/components/Button.html.twig', $display); + $this->assertStringContainsString('Anonymous', $display); + } + public function testWithoutPublicProps(): void { $commandTester = $this->createCommandTester(); diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php index db6b2ed655c..f36f9489e3f 100644 --- a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -169,6 +169,15 @@ public function testAnonymous(): void $this->factory()->metadataFor('anonymous:AButton'); } + public function testLoadingAnonymousComponentFromBundle(): void + { + $metadata = $this->factory()->metadataFor('Acme:Button'); + + $this->assertSame('@Acme/components/Button.html.twig', $metadata->getTemplate()); + $this->assertSame('Acme:Button', $metadata->getName()); + $this->assertNull($metadata->get('class')); + } + public function testAutoNamingInSubDirectory(): void { $metadata = $this->factory()->metadataFor('SubDirectory:ComponentInSubDirectory');