Skip to content

Adding convention to load Anonymous components from bundles #2019

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

Merged
merged 1 commit into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.20.0

- Add Anonymous Component support for 3rd-party bundles #2019

## 2.17.0

- Add nested attribute support #1405
Expand Down
48 changes: 43 additions & 5 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

<twig:Shadcn:Button type="primary">
Click me
</twig:Shadcn:Button>

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 ``<twig:Shadcn:Button>`` should have its template
file at ``templates/components/Button.html.twig`` within the Shadcn bundle.

Debugging Components
--------------------

Expand All @@ -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:
Expand All @@ -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 |
Expand Down
40 changes: 37 additions & 3 deletions src/TwigComponent/src/Command/TwigComponentDebugCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
10 changes: 10 additions & 0 deletions src/TwigComponent/src/ComponentTemplateFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Bundle/AcmeBundle/AcmeBundle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?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\Tests\Fixtures\Bundle\AcmeBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class AcmeBundle extends Bundle
{
public function getPath(): string
{
return __DIR__;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

2 changes: 2 additions & 0 deletions src/TwigComponent/tests/Fixtures/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down