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');