From 09b1d86d3b4d9fcfeddfae6da629b79629ebbd92 Mon Sep 17 00:00:00 2001 From: jannes Date: Sun, 23 Mar 2025 20:25:37 +0100 Subject: [PATCH 01/58] [LiveComponent] add LiveProp name to modifier function --- src/LiveComponent/CHANGELOG.md | 4 ++ src/LiveComponent/doc/index.rst | 41 +++++++++++++++++++ .../src/Metadata/LivePropMetadata.php | 2 +- .../Unit/Metadata/LivePropMetadataTest.php | 2 +- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 7ba0351676c..c7b7458ef52 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.25.0 + +- Add property name as second paramter to LiveProp modifier callback + ## 2.23.0 - Allow configuring the secret used to compute fingerprints and checksums. diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index a473881d50b..346fb9b0382 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2651,6 +2651,47 @@ This way you can also use the component multiple times in the same page and avoi +.. versionadded:: 2.25 + + The property name is passed into the modifier function since LiveComponents 2.25. + +The ``modifier`` function can also take the name of the property as a secondary parameter. +It can be used to perform more generic operations inside of the modifier that can be re-used for multiple props:: + + abstract class AbstractSearchModule + { + #[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')] + public string $query = ''; + + protected string $urlPrefix = ''; + + public function modifyQueryProp(LiveProp $liveProp, string $propName): LiveProp + { + if ($this->urlPrefix) { + return $liveProp->withUrl(new UrlMapping(as: $this->urlPrefix.'-'.$propName)); + } + return $liveProp; + } + } + + #[AsLiveComponent] + class ImportantSearchModule extends AbstractSearchModule + { + } + + #[AsLiveComponent] + class SecondarySearchModule extends AbstractSearchModule + { + protected string $urlPrefix = 'secondary'; + } + +.. code-block:: html+twig + + + + +The ``query`` value will appear in the URL like ``/search?query=my+important+query&secondary-query=my+secondary+query``. + Validating the Query Parameter Values ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/LiveComponent/src/Metadata/LivePropMetadata.php b/src/LiveComponent/src/Metadata/LivePropMetadata.php index b6a94b7f163..5fe8c154065 100644 --- a/src/LiveComponent/src/Metadata/LivePropMetadata.php +++ b/src/LiveComponent/src/Metadata/LivePropMetadata.php @@ -135,7 +135,7 @@ public function withModifier(object $component): self throw new \LogicException(\sprintf('Method "%s::%s()" given in LiveProp "modifier" does not exist.', $component::class, $modifier)); } - $modifiedLiveProp = $component->{$modifier}($this->liveProp); + $modifiedLiveProp = $component->{$modifier}($this->liveProp, $this->getName()); if (!$modifiedLiveProp instanceof LiveProp) { throw new \LogicException(\sprintf('Method "%s::%s()" should return an instance of "%s" (given: "%s").', $component::class, $modifier, LiveProp::class, get_debug_type($modifiedLiveProp))); } diff --git a/src/LiveComponent/tests/Unit/Metadata/LivePropMetadataTest.php b/src/LiveComponent/tests/Unit/Metadata/LivePropMetadataTest.php index bca2d262f74..b3ffd9f1bfa 100644 --- a/src/LiveComponent/tests/Unit/Metadata/LivePropMetadataTest.php +++ b/src/LiveComponent/tests/Unit/Metadata/LivePropMetadataTest.php @@ -29,7 +29,7 @@ public function testWithModifier() $component ->expects($this->once()) ->method('modifyProp') - ->with($liveProp) + ->with($liveProp, 'propWithModifier') ->willReturn($liveProp->withFieldName('customField')); $livePropMetadata = $livePropMetadata->withModifier($component); From 208952be4da94c8d89555e7f746c3fece022dfdd Mon Sep 17 00:00:00 2001 From: seb-jean Date: Sun, 2 Mar 2025 14:25:12 +0100 Subject: [PATCH 02/58] Add Twig Extensions for page refreshes --- src/Turbo/CHANGELOG.md | 4 ++ src/Turbo/doc/index.rst | 68 ++++++++++++++++++ src/Turbo/src/Twig/TwigExtension.php | 103 +++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md index 5a0f7d0f4f5..9bf757acdb0 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.24.0 + +- Add Twig Extensions for `meta` tags + ## 2.22.0 - Add `` component diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst index 68b6f8723e5..9f5a5b7a8f9 100644 --- a/src/Turbo/doc/index.rst +++ b/src/Turbo/doc/index.rst @@ -1050,6 +1050,74 @@ because these classes implement the ``BroadcasterInterface`` and ``TurboStreamListenRendererInterface`` interfaces, the related services will be. +Meta Tags +~~~~~~~~~ + +turbo_exempts_page_from_cache +............................. + +.. code-block:: twig + + {{ turbo_exempts_page_from_cache() }} + +Generates a tag to disable caching of a page. + +turbo_exempts_page_from_preview +............................... + +.. code-block:: twig + + {{ turbo_exempts_page_from_preview() }} + +Generates a tag to specify cached version of the page should not be shown as a preview on regular navigation visits. + +turbo_page_requires_reload +.......................... + +.. code-block:: twig + + {{ turbo_page_requires_reload() }} + +Generates a tag to force a full page reload. + +turbo_refreshes_with +.................... + +.. code-block:: twig + + {{ turbo_refreshes_with(method: 'replace', scroll: 'reset') }} + +``method`` *(optional)* + **type**: ``string`` **default**: ``replace`` **allowed values**: ``replace`` or ``morph`` +``scroll`` *(optional)* + **type**: ``string`` **default**: ``reset`` **allowed values**: ``reset`` or ``preserve`` + +Generates tags to configure both the refresh method and scroll behavior for page refreshes. + +turbo_refresh_method +.................... + +.. code-block:: twig + + {{ turbo_refresh_method(method: 'replace') }} + +``method`` *(optional)* + **type**: ``string`` **default**: ``replace`` **allowed values**: ``replace`` or ``morph`` + +Generates a tag to configure the refresh method for page refreshes. + +turbo_refresh_scroll +.................... + +.. code-block:: twig + + {{ turbo_refresh_scroll(scroll: 'reset') }} + +``scroll`` *(optional)* + **type**: ``string`` **default**: ``reset`` **allowed values**: ``reset`` or ``preserve`` + +Generates a tag to configure the scroll behavior for page refreshes. + Backward Compatibility promise ------------------------------ diff --git a/src/Turbo/src/Twig/TwigExtension.php b/src/Turbo/src/Twig/TwigExtension.php index b44d993139f..eb7aedfe806 100644 --- a/src/Turbo/src/Twig/TwigExtension.php +++ b/src/Turbo/src/Twig/TwigExtension.php @@ -22,6 +22,12 @@ */ final class TwigExtension extends AbstractExtension { + private const REFRESH_METHOD_REPLACE = 'replace'; + private const REFRESH_METHOD_MORPH = 'morph'; + + private const REFRESH_SCROLL_RESET = 'reset'; + private const REFRESH_SCROLL_PRESERVE = 'preserve'; + public function __construct( private ContainerInterface $turboStreamListenRenderers, private string $default, @@ -32,6 +38,12 @@ public function getFunctions(): array { return [ new TwigFunction('turbo_stream_listen', $this->turboStreamListen(...), ['needs_environment' => true, 'is_safe' => ['html']]), + new TwigFunction('turbo_exempts_page_from_cache', $this->turboExemptsPageFromCache(...), ['is_safe' => ['html']]), + new TwigFunction('turbo_exempts_page_from_preview', $this->turboExemptsPageFromPreview(...), ['is_safe' => ['html']]), + new TwigFunction('turbo_page_requires_reload', $this->turboPageRequiresReload(...), ['is_safe' => ['html']]), + new TwigFunction('turbo_refreshes_with', $this->turboRefreshesWith(...), ['is_safe' => ['html']]), + new TwigFunction('turbo_refresh_method', $this->turboRefreshMethod(...), ['is_safe' => ['html']]), + new TwigFunction('turbo_refresh_scroll', $this->turboRefreshScroll(...), ['is_safe' => ['html']]), ]; } @@ -52,4 +64,95 @@ public function turboStreamListen(Environment $env, $topic, ?string $transport = return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic); } + + /** + * Generates a tag to disable caching of a page. + * + * Inspired by Turbo Rails + * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). + */ + public function turboExemptsPageFromCache(): string + { + return ''; + } + + /** + * Generates a tag to specify cached version of the page should not be shown as a preview on regular navigation visits. + * + * Inspired by Turbo Rails + * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). + */ + public function turboExemptsPageFromPreview(): string + { + return ''; + } + + /** + * Generates a tag to force a full page reload. + * + * Inspired by Turbo Rails + * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). + */ + public function turboPageRequiresReload(): string + { + return ''; + } + + /** + * Generates tags to configure both the refresh method and scroll behavior for page refreshes. + * + * Inspired by Turbo Rails + * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). + * + * @param string $method The refresh method. Must be either 'replace' or 'morph'. + * @param string $scroll The scroll behavior. Must be either 'reset' or 'preserve'. + * + * @return string The tags for the specified refresh method and scroll behavior + */ + public function turboRefreshesWith(string $method = self::REFRESH_METHOD_REPLACE, string $scroll = self::REFRESH_SCROLL_RESET): string + { + return $this->turboRefreshMethod($method).$this->turboRefreshScroll($scroll); + } + + /** + * Generates a tag to configure the refresh method for page refreshes. + * + * Inspired by Turbo Rails + * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). + * + * @param string $method The refresh method. Must be either 'replace' or 'morph'. + * + * @return string The tag for the specified refresh method + * + * @throws \InvalidArgumentException If an invalid refresh method is provided + */ + public function turboRefreshMethod(string $method = self::REFRESH_METHOD_REPLACE): string + { + if (!\in_array($method, [self::REFRESH_METHOD_REPLACE, self::REFRESH_METHOD_MORPH], true)) { + throw new \InvalidArgumentException(\sprintf('Invalid refresh option "%s".', $method)); + } + + return \sprintf('', $method); + } + + /** + * Generates a tag to configure the scroll behavior for page refreshes. + * + * Inspired by Turbo Rails + * ({@see https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/drive_helper.rb}). + * + * @param string $scroll The scroll behavior. Must be either 'reset' or 'preserve'. + * + * @return string The tag for the specified scroll behavior + * + * @throws \InvalidArgumentException If an invalid scroll behavior is provided + */ + public function turboRefreshScroll(string $scroll = self::REFRESH_SCROLL_RESET): string + { + if (!\in_array($scroll, [self::REFRESH_SCROLL_RESET, self::REFRESH_SCROLL_PRESERVE], true)) { + throw new \InvalidArgumentException(\sprintf('Invalid scroll option "%s".', $scroll)); + } + + return \sprintf('', $scroll); + } } From 2197b93223093a7652c991562f5b744908c5ae77 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 25 Mar 2025 22:24:14 +0100 Subject: [PATCH 03/58] [Map] Make renderer tests way easier to maintain, use snapshots --- src/Map/src/Bridge/Google/composer.json | 3 ++- src/Map/src/Bridge/Google/phpunit.xml.dist | 1 + .../Google/tests/GoogleRendererTest.php | 16 +----------- ...ap with data set markers with icons__1.txt | 13 ++++++++++ ...et simple map, with minimum options__1.txt | 13 ++++++++++ ...h data set with all markers removed__1.txt | 13 ++++++++++ ...with data set with controls enabled__1.txt | 13 ++++++++++ ...ith data set with custom attributes__1.txt | 14 ++++++++++ ...t map id overridden by option mapId__1.txt | 13 ++++++++++ ... passing options (except the mapId)__1.txt | 13 ++++++++++ ...p with data set with default map id__1.txt | 13 ++++++++++ ...ap with data set with every options__1.txt | 13 ++++++++++ ...th marker remove and new ones added__1.txt | 13 ++++++++++ ...ta set with markers and infoWindows__1.txt | 13 ++++++++++ ...a set with polygons and infoWindows__1.txt | 13 ++++++++++ ... set with polylines and infoWindows__1.txt | 13 ++++++++++ ...h data set without controls enabled__1.txt | 13 ++++++++++ src/Map/src/Bridge/Leaflet/composer.json | 3 ++- src/Map/src/Bridge/Leaflet/phpunit.xml.dist | 1 + .../Leaflet/tests/LeafletRendererTest.php | 10 +------ ...ap with data set markers with icons__1.txt | 13 ++++++++++ ...tRenderMap with data set simple map__1.txt | 13 ++++++++++ ...h data set with all markers removed__1.txt | 13 ++++++++++ ...ith data set with custom attributes__1.txt | 14 ++++++++++ ...th marker remove and new ones added__1.txt | 13 ++++++++++ ...ta set with markers and infoWindows__1.txt | 13 ++++++++++ ...a set with polygons and infoWindows__1.txt | 13 ++++++++++ ... set with polylines and infoWindows__1.txt | 13 ++++++++++ src/Map/src/Test/RendererFactoryTestCase.php | 4 +++ src/Map/src/Test/RendererTestCase.php | 26 ++++++++++++++++--- 30 files changed, 322 insertions(+), 30 deletions(-) create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt create mode 100644 src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt create mode 100644 src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt diff --git a/src/Map/src/Bridge/Google/composer.json b/src/Map/src/Bridge/Google/composer.json index 9ae81496e79..00f77046a73 100644 --- a/src/Map/src/Bridge/Google/composer.json +++ b/src/Map/src/Bridge/Google/composer.json @@ -22,7 +22,8 @@ }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0", - "symfony/ux-icons": "^2.18" + "symfony/ux-icons": "^2.18", + "spatie/phpunit-snapshot-assertions": "^5.1.8" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Google\\": "src/" }, diff --git a/src/Map/src/Bridge/Google/phpunit.xml.dist b/src/Map/src/Bridge/Google/phpunit.xml.dist index 1c3807e6255..4049eb2ea15 100644 --- a/src/Map/src/Bridge/Google/phpunit.xml.dist +++ b/src/Map/src/Bridge/Google/phpunit.xml.dist @@ -16,6 +16,7 @@ + diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index dcb35dae2ce..a2f7e7dc127 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -27,7 +27,7 @@ class GoogleRendererTest extends RendererTestCase { - public function provideTestRenderMap(): iterable + public static function provideTestRenderMap(): iterable { $map = (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -37,26 +37,22 @@ public function provideTestRenderMap(): iterable $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map, with minimum options' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => $map, ]; yield 'with every options' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), 'map' => $map, ]; yield 'with custom attributes' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => $map, 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -66,7 +62,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with all markers removed' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -78,7 +73,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with marker remove and new ones added' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -90,7 +84,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with polygons and infoWindows' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -100,7 +93,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with polylines and infoWindows' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -110,7 +102,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with controls enabled' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -124,7 +115,6 @@ public function provideTestRenderMap(): iterable ]; yield 'without controls enabled' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -138,7 +128,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with default map id' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -146,7 +135,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with default map id, when passing options (except the "mapId")' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -155,7 +143,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with default map id overridden by option "mapId"' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -164,7 +151,6 @@ public function provideTestRenderMap(): iterable ]; yield 'markers with icons' => [ - 'expected_render' => '
', 'renderer' => new GoogleRenderer( new StimulusHelper(null), new UxIconRenderer(new class implements IconRendererInterface { diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt new file mode 100644 index 00000000000..155943865d5 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt new file mode 100644 index 00000000000..91cbdc7634d --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt new file mode 100644 index 00000000000..91cbdc7634d --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt new file mode 100644 index 00000000000..91cbdc7634d --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt new file mode 100644 index 00000000000..f1f0a9a5909 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt @@ -0,0 +1,14 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt new file mode 100644 index 00000000000..365f78a9269 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt new file mode 100644 index 00000000000..6d398ba08c5 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt new file mode 100644 index 00000000000..6d398ba08c5 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt new file mode 100644 index 00000000000..db10b08c850 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt new file mode 100644 index 00000000000..04d1e3965fb --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt new file mode 100644 index 00000000000..c185e4fb2a1 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt new file mode 100644 index 00000000000..76f32b102f7 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt new file mode 100644 index 00000000000..a1d6ecf8754 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt new file mode 100644 index 00000000000..3e44c36583a --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/composer.json b/src/Map/src/Bridge/Leaflet/composer.json index 32fc6619d63..3d5b74b14a6 100644 --- a/src/Map/src/Bridge/Leaflet/composer.json +++ b/src/Map/src/Bridge/Leaflet/composer.json @@ -22,7 +22,8 @@ }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0", - "symfony/ux-icons": "^2.18" + "symfony/ux-icons": "^2.18", + "spatie/phpunit-snapshot-assertions": "^5.1.8" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\": "src/" }, diff --git a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist index 1c3807e6255..4049eb2ea15 100644 --- a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist +++ b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist @@ -16,6 +16,7 @@ + diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index 47e5da7cbe2..da0a05c2f56 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -26,7 +26,7 @@ class LeafletRendererTest extends RendererTestCase { - public function provideTestRenderMap(): iterable + public static function provideTestRenderMap(): iterable { $map = (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -37,20 +37,17 @@ public function provideTestRenderMap(): iterable $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (clone $map), ]; yield 'with custom attributes' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (clone $map), 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -60,7 +57,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with all markers removed' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -72,7 +68,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with marker remove and new ones added' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -84,7 +79,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with polygons and infoWindows' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -94,7 +88,6 @@ public function provideTestRenderMap(): iterable ]; yield 'with polylines and infoWindows' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -104,7 +97,6 @@ public function provideTestRenderMap(): iterable ]; yield 'markers with icons' => [ - 'expected_render' => '
', 'renderer' => new LeafletRenderer( new StimulusHelper(null), new UxIconRenderer(new class implements IconRendererInterface { diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt new file mode 100644 index 00000000000..4aa53a326da --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt new file mode 100644 index 00000000000..2a39e666909 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt new file mode 100644 index 00000000000..2a39e666909 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt new file mode 100644 index 00000000000..0c4ece6a8fa --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt @@ -0,0 +1,14 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt new file mode 100644 index 00000000000..04afe48ecc9 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt new file mode 100644 index 00000000000..64f60cc82c5 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt new file mode 100644 index 00000000000..e5439223b31 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt new file mode 100644 index 00000000000..be6a8953c44 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
\ No newline at end of file diff --git a/src/Map/src/Test/RendererFactoryTestCase.php b/src/Map/src/Test/RendererFactoryTestCase.php index 6d8914ef2b1..6a254c392ce 100644 --- a/src/Map/src/Test/RendererFactoryTestCase.php +++ b/src/Map/src/Test/RendererFactoryTestCase.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Map\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\UX\Map\Exception\UnsupportedSchemeException; use Symfony\UX\Map\Renderer\Dsn; @@ -55,6 +56,7 @@ public static function incompleteDsnRenderer(): iterable /** * @dataProvider supportsRenderer */ + #[DataProvider('supportsRenderer')] public function testSupports(bool $expected, string $dsn): void { $factory = $this->createRendererFactory(); @@ -65,6 +67,7 @@ public function testSupports(bool $expected, string $dsn): void /** * @dataProvider createRenderer */ + #[DataProvider('createRenderer')] public function testCreate(string $expected, string $dsn): void { $factory = $this->createRendererFactory(); @@ -76,6 +79,7 @@ public function testCreate(string $expected, string $dsn): void /** * @dataProvider unsupportedSchemeRenderer */ + #[DataProvider('unsupportedSchemeRenderer')] public function testUnsupportedSchemeException(string $dsn, ?string $message = null): void { $factory = $this->createRendererFactory(); diff --git a/src/Map/src/Test/RendererTestCase.php b/src/Map/src/Test/RendererTestCase.php index b9c3fe07244..6e8ff909fef 100644 --- a/src/Map/src/Test/RendererTestCase.php +++ b/src/Map/src/Test/RendererTestCase.php @@ -11,7 +11,9 @@ namespace Symfony\UX\Map\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Spatie\Snapshots\MatchesSnapshots; use Symfony\UX\Map\Map; use Symfony\UX\Map\Renderer\RendererInterface; @@ -20,16 +22,32 @@ */ abstract class RendererTestCase extends TestCase { + use MatchesSnapshots; + /** - * @return iterable}> + * @return iterable}> */ - abstract public function provideTestRenderMap(): iterable; + abstract public static function provideTestRenderMap(): iterable; /** * @dataProvider provideTestRenderMap */ - public function testRenderMap(string $expectedRender, RendererInterface $renderer, Map $map, array $attributes = []): void + #[DataProvider('provideTestRenderMap')] + public function testRenderMap(RendererInterface $renderer, Map $map, array $attributes = []): void + { + $rendered = $renderer->renderMap($map, $attributes); + $rendered = $this->prettify($rendered); + + $this->assertMatchesSnapshot($rendered); + } + + private function prettify(string $html): string { - self::assertSame($expectedRender, $renderer->renderMap($map, $attributes)); + $html = preg_replace('/ ([a-zA-Z-]+=")/', "\n $1", $html); + $html = str_replace('">', "\"\n>", $html); + $html = ''."\n".$html; + + return $html; } } From 79113fef45a71c83617ab46be67984558cef48b7 Mon Sep 17 00:00:00 2001 From: zak39 Date: Tue, 1 Apr 2025 23:44:17 +0200 Subject: [PATCH 04/58] chore: Adjust changelog for features moving to 2.24 release The following features were moved in the changelog as they are not present in the 2.23 release, but will be included in the 2.24 release - `id` to `Marker`, `Polygon` and `Polyline` constructors - `Map::removeMarker(string|Marker $markerOrId)` - `Map::removePolygon(string|Polygon $polygonOrId)` - `Map::removePolyline(string|Polyline $polylineOrId)` --- src/Map/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index 1ab7242f78c..b75836888fe 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -4,6 +4,10 @@ - Installing the package in a Symfony app using Flex won't add the `@symfony/ux-map` dependency to the `package.json` file anymore. - Add `Icon` to customize a `Marker` icon (URL or SVG content) +- Add parameter `id` to `Marker`, `Polygon` and `Polyline` constructors +- Add method `Map::removeMarker(string|Marker $markerOrId)` +- Add method `Map::removePolygon(string|Polygon $polygonOrId)` +- Add method `Map::removePolyline(string|Polyline $polylineOrId)` ## 2.23 @@ -12,10 +16,6 @@ - Add `DistanceCalculatorInterface` interface and three implementations: `HaversineDistanceCalculator`, `SphericalCosineDistanceCalculator` and `VincentyDistanceCalculator`. - Add `CoordinateUtils` helper, to convert decimal coordinates (`43.2109`) in DMS (`56° 78' 90"`) -- Add parameter `id` to `Marker`, `Polygon` and `Polyline` constructors -- Add method `Map::removeMarker(string|Marker $markerOrId)` -- Add method `Map::removePolygon(string|Polygon $polygonOrId)` -- Add method `Map::removePolyline(string|Polyline $polylineOrId)` ## 2.22 From 67eb6b59087296eef5840d2020008cd6d75b133a Mon Sep 17 00:00:00 2001 From: David Buchmann Date: Sat, 29 Mar 2025 21:27:59 +0100 Subject: [PATCH 05/58] improve setup instructions i missed that i need the http-client installed, the bundle silently reduces functionality without it. make it more clear what needs to be installed --- src/Icons/doc/index.rst | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index a20222fd244..5700e12a7b5 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -16,6 +16,15 @@ Installation $ composer require symfony/ux-icons +HTTP Client for On-Demand Icons +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you plan to use provided icon sets, make sure that you have the HTTP client installed: + +.. code-block:: terminal + + $ composer require symfony/http-client + SVG Icons --------- @@ -63,15 +72,6 @@ Loading Icons and embeds the downloaded SVG contents in the template #} {{ ux_icon('flowbite:user-solid') }} -.. note:: - - To search and download icons via `ux.symfony.com/icons`_, the ``symfony/http-client`` - package must be installed in your application: - - .. code-block:: terminal - - $ composer require symfony/http-client - The ``ux_icon()`` function defines a second optional argument where you can define the HTML attributes added to the ```` element: @@ -87,6 +87,15 @@ define the HTML attributes added to the ```` element: Icon Sets ~~~~~~~~~ +.. note:: + + To use icons from icon sets via `ux.symfony.com/icons`_, the ``symfony/http-client`` + package must be installed in your application: + + .. code-block:: terminal + + $ composer require symfony/http-client + There are many icon sets available, each with their own unique style and set of icons, providing a wide range of icons for different purposes, while maintaining a consistent look and feel across your application. Here are some of the most @@ -166,7 +175,7 @@ HTML Syntax In addition to the ``ux_icon()`` function explained in the previous sections, this package also supports an alternative HTML syntax based on the ```` -tag: +tag if the ``symfony/ux-twig-component`` package is installed: .. code-block:: html From cb989bbeb4adbca845f5a4035d9cf6f496e5cff6 Mon Sep 17 00:00:00 2001 From: Sven Scholz Date: Fri, 17 May 2024 09:56:47 +0200 Subject: [PATCH 06/58] [Notify] Added body, icon, tag ,renotify to controller.js for mercure-notifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon André --- src/Notify/CHANGELOG.md | 4 ++++ src/Notify/assets/dist/controller.d.ts | 2 +- src/Notify/assets/dist/controller.js | 13 ++++++++----- src/Notify/assets/src/controller.ts | 15 ++++++++++----- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Notify/CHANGELOG.md b/src/Notify/CHANGELOG.md index ef21c92f59e..e1daa04d829 100644 --- a/src/Notify/CHANGELOG.md +++ b/src/Notify/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.24.0 + +- Added `options` to Notification + ## 2.13.2 - Revert "Change JavaScript package to `type: module`" diff --git a/src/Notify/assets/dist/controller.d.ts b/src/Notify/assets/dist/controller.d.ts index 73ba68163eb..e9252002f3c 100644 --- a/src/Notify/assets/dist/controller.d.ts +++ b/src/Notify/assets/dist/controller.d.ts @@ -13,6 +13,6 @@ export default class extends Controller { initialize(): void; connect(): void; disconnect(): void; - _notify(content: string | undefined): void; + _notify(title: string | undefined, options: NotificationOptions | undefined): void; private dispatchEvent; } diff --git a/src/Notify/assets/dist/controller.js b/src/Notify/assets/dist/controller.js index 7350487c71d..a69ae1a254b 100644 --- a/src/Notify/assets/dist/controller.js +++ b/src/Notify/assets/dist/controller.js @@ -26,7 +26,10 @@ class default_1 extends Controller { return; } this.eventSources.forEach((eventSource) => { - const listener = (event) => this._notify(JSON.parse(event.data).summary); + const listener = (event) => { + const { summary, content } = JSON.parse(event.data); + this._notify(summary, content); + }; eventSource.addEventListener('message', listener); this.listeners.set(eventSource, listener); }); @@ -42,17 +45,17 @@ class default_1 extends Controller { }); this.eventSources = []; } - _notify(content) { - if (!content) + _notify(title, options) { + if (!title) return; if ('granted' === Notification.permission) { - new Notification(content); + new Notification(title, options); return; } if ('denied' !== Notification.permission) { Notification.requestPermission().then((permission) => { if ('granted' === permission) { - new Notification(content); + new Notification(title, options); } }); } diff --git a/src/Notify/assets/src/controller.ts b/src/Notify/assets/src/controller.ts index 6a5571fa0b9..db1f206f9c8 100644 --- a/src/Notify/assets/src/controller.ts +++ b/src/Notify/assets/src/controller.ts @@ -49,7 +49,12 @@ export default class extends Controller { } this.eventSources.forEach((eventSource) => { - const listener = (event: MessageEvent) => this._notify(JSON.parse(event.data).summary); + const listener = (event: MessageEvent) => { + const { summary, content } = JSON.parse(event.data); + + this._notify(summary, content); + }; + eventSource.addEventListener('message', listener); this.listeners.set(eventSource, listener); }); @@ -70,11 +75,11 @@ export default class extends Controller { this.eventSources = []; } - _notify(content: string | undefined) { - if (!content) return; + _notify(title: string | undefined, options: NotificationOptions | undefined) { + if (!title) return; if ('granted' === Notification.permission) { - new Notification(content); + new Notification(title, options); return; } @@ -82,7 +87,7 @@ export default class extends Controller { if ('denied' !== Notification.permission) { Notification.requestPermission().then((permission) => { if ('granted' === permission) { - new Notification(content); + new Notification(title, options); } }); } From 1fc98e5dbda45c0d1716a0e90910e1748e569c73 Mon Sep 17 00:00:00 2001 From: Enzo Santamaria <62953579+Enz000@users.noreply.github.com> Date: Thu, 27 Mar 2025 22:34:18 +0100 Subject: [PATCH 07/58] [Icons] Add xmlns attribute to svg icons --- src/Icons/CHANGELOG.md | 6 ++++++ src/Icons/src/Iconify.php | 3 +++ src/Icons/tests/Integration/RenderIconsInTwigTest.php | 6 +++--- src/Icons/tests/Unit/IconifyTest.php | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md index 8ff2aad1fb0..48a8ba77b5a 100644 --- a/src/Icons/CHANGELOG.md +++ b/src/Icons/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.24.0 + +- Add `xmlns` attribute to icons downloaded with Iconify, to correctly render icons browser as an external file, in SVG editors, and in files explorers or text editors previews. +It **may breaks your pipeline** if you assert on `ux_icon()` or `` output in your tests, and forgot [to lock your icons](https://symfony.com/bundles/ux-icons/current/index.html#locking-on-demand-icons). +We recommend you to **lock** your icons **before** upgrading to UX Icons 2.24. We also suggest you to to **force-lock** your icons **after** upgrading to UX Icons 2.24, to add the attribute `xmlns` to your icons already downloaded from Iconify. + ## 2.20.0 - Add `aliases` configuration option to define icon alternative names. diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php index fcbebe81031..55c5051d335 100644 --- a/src/Icons/src/Iconify.php +++ b/src/Icons/src/Iconify.php @@ -26,6 +26,7 @@ final class Iconify { public const API_ENDPOINT = 'https://api.iconify.design'; + private const ATTR_XMLNS_URL = 'https://www.w3.org/2000/svg'; // URL must be 500 chars max (iconify limit) // -39 chars: https://api.iconify.design/XXX.json?icons= @@ -89,6 +90,7 @@ public function fetchIcon(string $prefix, string $name): Icon } return new Icon($data['icons'][$name]['body'], [ + 'xmlns' => self::ATTR_XMLNS_URL, 'viewBox' => \sprintf('0 0 %s %s', $width ?? $height, $height ?? $width), ]); } @@ -136,6 +138,7 @@ public function fetchIcons(string $prefix, array $names): array $width = $iconData['width'] ?? $data['width'] ??= $this->sets()[$prefix]['width'] ?? null; $icons[$iconName] = new Icon($iconData['body'], [ + 'xmlns' => self::ATTR_XMLNS_URL, 'viewBox' => \sprintf('0 0 %d %d', $width ?? $height, $height ?? $width), ]); } diff --git a/src/Icons/tests/Integration/RenderIconsInTwigTest.php b/src/Icons/tests/Integration/RenderIconsInTwigTest.php index 8789f29a150..feba3097dbe 100644 --- a/src/Icons/tests/Integration/RenderIconsInTwigTest.php +++ b/src/Icons/tests/Integration/RenderIconsInTwigTest.php @@ -33,8 +33,8 @@ public function testRenderIcons(): void
  • -
  • -
  • +
  • +
  • HTML, trim($output) @@ -49,7 +49,7 @@ public function testRenderAliasIcons(): void $templateAlias = ''; $outputAlias = self::getContainer()->get(Environment::class)->createTemplate($templateAlias)->render(); - $expected = ''; + $expected = ''; $this->assertSame($outputIcon, $expected); $this->assertSame($outputIcon, $outputAlias); } diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php index c57e37ccccb..cec6172cb7d 100644 --- a/src/Icons/tests/Unit/IconifyTest.php +++ b/src/Icons/tests/Unit/IconifyTest.php @@ -47,7 +47,7 @@ public function testFetchIcon(): void $icon = $iconify->fetchIcon('bi', 'heart'); $this->assertEquals($icon->getInnerSvg(), ''); - $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24']); + $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24', 'xmlns' => 'https://www.w3.org/2000/svg']); } public function testFetchIconByAlias(): void @@ -78,7 +78,7 @@ public function testFetchIconByAlias(): void $icon = $iconify->fetchIcon('bi', 'foo'); $this->assertEquals($icon->getInnerSvg(), ''); - $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24']); + $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24', 'xmlns' => 'https://www.w3.org/2000/svg']); } public function testFetchIconThrowsWhenIconSetDoesNotExists(): void From adf15e70b70bdedd8c89f5649652d32ab2890377 Mon Sep 17 00:00:00 2001 From: Fan2Shrek Date: Thu, 12 Dec 2024 22:21:24 +0100 Subject: [PATCH 08/58] [Turbo] Add support for authentication to the EventSource via `turbo_stream_listen` --- src/Turbo/CHANGELOG.md | 1 + .../assets/dist/turbo_stream_controller.d.ts | 2 + .../assets/dist/turbo_stream_controller.js | 3 +- .../assets/src/turbo_stream_controller.ts | 4 +- src/Turbo/config/services.php | 9 ++- src/Turbo/doc/index.rst | 3 + .../Mercure/TurboStreamListenRenderer.php | 25 ++++++++- .../Compiler/RegisterMercureHubsPass.php | 53 ++++++++++++++++++ .../DependencyInjection/TurboExtension.php | 2 +- src/Turbo/src/TurboBundle.php | 3 + src/Turbo/src/Twig/TurboRuntime.php | 55 +++++++++++++++++++ .../TurboStreamListenRendererInterface.php | 2 +- ...reamListenRendererWithOptionsInterface.php | 19 +++++++ src/Turbo/src/Twig/TwigExtension.php | 29 +--------- .../Mercure/TurboStreamListenRendererTest.php | 8 +++ .../Compiler/RegisterMercureHubsPassTest.php | 51 +++++++++++++++++ 16 files changed, 233 insertions(+), 36 deletions(-) create mode 100644 src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php create mode 100644 src/Turbo/src/Twig/TurboRuntime.php create mode 100644 src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php create mode 100644 src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md index 9bf757acdb0..fef22f69cf1 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.24.0 - Add Twig Extensions for `meta` tags +- Add support for authentication to the EventSource via `turbo_stream_listen` ## 2.22.0 diff --git a/src/Turbo/assets/dist/turbo_stream_controller.d.ts b/src/Turbo/assets/dist/turbo_stream_controller.d.ts index 2806afea3cc..cc4db88a562 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.d.ts +++ b/src/Turbo/assets/dist/turbo_stream_controller.d.ts @@ -4,11 +4,13 @@ export default class extends Controller { topic: StringConstructor; topics: ArrayConstructor; hub: StringConstructor; + withCredentials: BooleanConstructor; }; es: EventSource | undefined; url: string | undefined; readonly topicValue: string; readonly topicsValue: string[]; + readonly withCredentialsValue: boolean; readonly hubValue: string; readonly hasHubValue: boolean; readonly hasTopicValue: boolean; diff --git a/src/Turbo/assets/dist/turbo_stream_controller.js b/src/Turbo/assets/dist/turbo_stream_controller.js index 3d55567c772..d5962232feb 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.js +++ b/src/Turbo/assets/dist/turbo_stream_controller.js @@ -23,7 +23,7 @@ class default_1 extends Controller { } connect() { if (this.url) { - this.es = new EventSource(this.url); + this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); connectStreamSource(this.es); } } @@ -38,6 +38,7 @@ default_1.values = { topic: String, topics: Array, hub: String, + withCredentials: Boolean, }; export { default_1 as default }; diff --git a/src/Turbo/assets/src/turbo_stream_controller.ts b/src/Turbo/assets/src/turbo_stream_controller.ts index 4c8fd4d915a..aaa19c78396 100644 --- a/src/Turbo/assets/src/turbo_stream_controller.ts +++ b/src/Turbo/assets/src/turbo_stream_controller.ts @@ -18,12 +18,14 @@ export default class extends Controller { topic: String, topics: Array, hub: String, + withCredentials: Boolean, }; es: EventSource | undefined; url: string | undefined; declare readonly topicValue: string; declare readonly topicsValue: string[]; + declare readonly withCredentialsValue: boolean; declare readonly hubValue: string; declare readonly hasHubValue: boolean; declare readonly hasTopicValue: boolean; @@ -50,7 +52,7 @@ export default class extends Controller { connect() { if (this.url) { - this.es = new EventSource(this.url); + this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); connectStreamSource(this.es); } } diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index 0344e95a6e9..e3a3fabcde5 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -18,6 +18,7 @@ use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; use Symfony\UX\Turbo\Doctrine\BroadcastListener; use Symfony\UX\Turbo\Request\RequestListener; +use Symfony\UX\Turbo\Twig\TurboRuntime; use Symfony\UX\Turbo\Twig\TwigExtension; /* @@ -47,9 +48,15 @@ ->decorate('turbo.broadcaster.imux') ->set('turbo.twig.extension', TwigExtension::class) - ->args([tagged_locator('turbo.renderer.stream_listen', 'transport'), abstract_arg('default')]) ->tag('twig.extension') + ->set('turbo.twig.runtime', TurboRuntime::class) + ->args([ + tagged_locator('turbo.renderer.stream_listen', 'transport'), + abstract_arg('default_transport'), + ]) + ->tag('twig.runtime') + ->set('turbo.doctrine.event_listener', BroadcastListener::class) ->args([ service('turbo.broadcaster.imux'), diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst index 9f5a5b7a8f9..d215aaaa8df 100644 --- a/src/Turbo/doc/index.rst +++ b/src/Turbo/doc/index.rst @@ -754,6 +754,9 @@ Let's create our chat:: {% endblock %} +If you're using a private hub, you can add ``{ withCredentials: true }`` +as ``turbo_stream_listen()`` third argument to authenticate with the hub + .. code-block:: html+twig {# chat/message.stream.html.twig #} diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 68eadd82079..a5e511ca9e7 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -12,18 +12,20 @@ namespace Symfony\UX\Turbo\Bridge\Mercure; use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\Twig\MercureExtension; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Broadcaster\IdAccessor; -use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; +use Symfony\UX\Turbo\Twig\TurboStreamListenRendererWithOptionsInterface; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; use Twig\Environment; +use Twig\Error\RuntimeError; /** * Renders the attributes to load the "mercure-turbo-stream" controller. * * @author Kévin Dunglas */ -final class TurboStreamListenRenderer implements TurboStreamListenRendererInterface +final class TurboStreamListenRenderer implements TurboStreamListenRendererWithOptionsInterface { private StimulusHelper $stimulusHelper; @@ -31,6 +33,7 @@ public function __construct( private HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, private IdAccessor $idAccessor, + private Environment $twig, ) { if ($stimulus instanceof StimulusTwigExtension) { trigger_deprecation('symfony/ux-turbo', '2.9', 'Passing an instance of "%s" as second argument of "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); @@ -42,8 +45,12 @@ public function __construct( $this->stimulusHelper = $stimulus; } - public function renderTurboStreamListen(Environment $env, $topic): string + public function renderTurboStreamListen(Environment $env, $topic /* array $eventSourceOptions = [] */): string { + if (\func_num_args() > 2) { + $eventSourceOptions = func_get_arg(2); + } + $topics = $topic instanceof TopicSet ? array_map($this->resolveTopic(...), $topic->getTopics()) : [$this->resolveTopic($topic)]; @@ -55,6 +62,18 @@ public function renderTurboStreamListen(Environment $env, $topic): string $controllerAttributes['topic'] = current($topics); } + if (isset($eventSourceOptions)) { + try { + $mercure = $this->twig->getExtension(MercureExtension::class); + $mercure->mercure($topics, $eventSourceOptions); + + if (isset($eventSourceOptions['withCredentials'])) { + $controllerAttributes['withCredentials'] = $eventSourceOptions['withCredentials']; + } + } catch (RuntimeError $e) { + } + } + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); $stimulusAttributes->addController( 'symfony/ux-turbo/mercure-turbo-stream', diff --git a/src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php b/src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php new file mode 100644 index 00000000000..ebe97b68e98 --- /dev/null +++ b/src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\UX\Turbo\Bridge\Mercure\Broadcaster; +use Symfony\UX\Turbo\Bridge\Mercure\TurboStreamListenRenderer; + +/** + * This compiler pass ensures that TurboStreamListenRenderer + * and Broadcast are registered per Mercure hub. + * + * @author Pierre Ambroise + */ +final class RegisterMercureHubsPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + foreach ($container->findTaggedServiceIds('mercure.hub') as $hubId => $tags) { + $name = str_replace('mercure.hub.', '', $hubId); + + $container->register("turbo.mercure.$name.renderer", TurboStreamListenRenderer::class) + ->addArgument(new Reference($hubId)) + ->addArgument(new Reference('turbo.mercure.stimulus_helper')) + ->addArgument(new Reference('turbo.id_accessor')) + ->addArgument(new Reference('twig')) + ->addTag('turbo.renderer.stream_listen', ['transport' => $name]); + + foreach ($tags as $tag) { + if (isset($tag['default']) && $tag['default'] && 'default' !== $name) { + $container->getDefinition("turbo.mercure.$name.renderer") + ->addTag('turbo.renderer.stream_listen', ['transport' => 'default']); + } + } + + $container->register("turbo.mercure.$name.broadcaster", Broadcaster::class) + ->addArgument($name) + ->addArgument(new Reference($hubId)) + ->addTag('turbo.broadcaster'); + } + } +} diff --git a/src/Turbo/src/DependencyInjection/TurboExtension.php b/src/Turbo/src/DependencyInjection/TurboExtension.php index ca53f6d2b09..761d8e4b27e 100644 --- a/src/Turbo/src/DependencyInjection/TurboExtension.php +++ b/src/Turbo/src/DependencyInjection/TurboExtension.php @@ -37,7 +37,7 @@ public function load(array $configs, ContainerBuilder $container): void $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); $loader->load('services.php'); - $container->getDefinition('turbo.twig.extension')->replaceArgument(1, $config['default_transport']); + $container->getDefinition('turbo.twig.runtime')->replaceArgument(1, $config['default_transport']); $this->registerTwig($config, $container); $this->registerBroadcast($config, $container, $loader); diff --git a/src/Turbo/src/TurboBundle.php b/src/Turbo/src/TurboBundle.php index e1524e4ba34..ca149e8fb1a 100644 --- a/src/Turbo/src/TurboBundle.php +++ b/src/Turbo/src/TurboBundle.php @@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\Turbo\DependencyInjection\Compiler\RegisterMercureHubsPass; /** * @author Kévin Dunglas @@ -28,6 +29,8 @@ public function build(ContainerBuilder $container): void { parent::build($container); + $container->addCompilerPass(new RegisterMercureHubsPass()); + $container->addCompilerPass(new class implements CompilerPassInterface { public function process(ContainerBuilder $container): void { diff --git a/src/Turbo/src/Twig/TurboRuntime.php b/src/Turbo/src/Twig/TurboRuntime.php new file mode 100644 index 00000000000..30238c64ee3 --- /dev/null +++ b/src/Turbo/src/Twig/TurboRuntime.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Twig; + +use Psr\Container\ContainerInterface; +use Symfony\UX\Turbo\Bridge\Mercure\TopicSet; +use Twig\Environment; +use Twig\Extension\RuntimeExtensionInterface; + +/** + * @author Kévin Dunglas + * @author Pierre Ambroise + * + * @internal + */ +final class TurboRuntime implements RuntimeExtensionInterface +{ + public function __construct( + private ContainerInterface $turboStreamListenRenderers, + private readonly string $defaultTransport, + ) { + } + + /** + * @param object|string|array $topic + * @param array $options + */ + public function renderTurboStreamListen(Environment $env, $topic, ?string $transport = null, array $options = []): string + { + $options['transport'] = $transport ??= $this->defaultTransport; + + if (!$this->turboStreamListenRenderers->has($transport)) { + throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); + } + + if (\is_array($topic)) { + $topic = new TopicSet($topic); + } + + $renderer = $this->turboStreamListenRenderers->get($transport); + + return $renderer instanceof TurboStreamListenRendererWithOptionsInterface + ? $renderer->renderTurboStreamListen($env, $topic, $options) // @phpstan-ignore-line + : $renderer->renderTurboStreamListen($env, $topic); + } +} diff --git a/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php b/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php index 3670e40bd28..240721317f1 100644 --- a/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php +++ b/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php @@ -23,5 +23,5 @@ interface TurboStreamListenRendererInterface /** * @param string|object $topic */ - public function renderTurboStreamListen(Environment $env, $topic): string; + public function renderTurboStreamListen(Environment $env, $topic /* , array $eventSourceOptions = [] */): string; } diff --git a/src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php b/src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php new file mode 100644 index 00000000000..6364fe3b97b --- /dev/null +++ b/src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Twig; + +/** + * @internal + */ +interface TurboStreamListenRendererWithOptionsInterface extends TurboStreamListenRendererInterface +{ +} diff --git a/src/Turbo/src/Twig/TwigExtension.php b/src/Turbo/src/Twig/TwigExtension.php index eb7aedfe806..f3e992ce0a0 100644 --- a/src/Turbo/src/Twig/TwigExtension.php +++ b/src/Turbo/src/Twig/TwigExtension.php @@ -11,9 +11,6 @@ namespace Symfony\UX\Turbo\Twig; -use Psr\Container\ContainerInterface; -use Symfony\UX\Turbo\Bridge\Mercure\TopicSet; -use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -28,16 +25,10 @@ final class TwigExtension extends AbstractExtension private const REFRESH_SCROLL_RESET = 'reset'; private const REFRESH_SCROLL_PRESERVE = 'preserve'; - public function __construct( - private ContainerInterface $turboStreamListenRenderers, - private string $default, - ) { - } - public function getFunctions(): array { return [ - new TwigFunction('turbo_stream_listen', $this->turboStreamListen(...), ['needs_environment' => true, 'is_safe' => ['html']]), + new TwigFunction('turbo_stream_listen', [TurboRuntime::class, 'renderTurboStreamListen'], ['needs_environment' => true, 'is_safe' => ['html']]), new TwigFunction('turbo_exempts_page_from_cache', $this->turboExemptsPageFromCache(...), ['is_safe' => ['html']]), new TwigFunction('turbo_exempts_page_from_preview', $this->turboExemptsPageFromPreview(...), ['is_safe' => ['html']]), new TwigFunction('turbo_page_requires_reload', $this->turboPageRequiresReload(...), ['is_safe' => ['html']]), @@ -47,24 +38,6 @@ public function getFunctions(): array ]; } - /** - * @param object|string|array $topic - */ - public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string - { - $transport ??= $this->default; - - if (!$this->turboStreamListenRenderers->has($transport)) { - throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); - } - - if (\is_array($topic)) { - $topic = new TopicSet($topic); - } - - return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic); - } - /** * Generates a tag to disable caching of a page. * diff --git a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php index 9b19ba4db09..108715b06f8 100644 --- a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php +++ b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php @@ -71,5 +71,13 @@ public static function provideTestCases(): iterable ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="["a_topic","AppEntityBook","https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123"]"' : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="["a_topic","AppEntityBook","https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123"]"', ]; + + yield [ + "{{ turbo_stream_listen('a_topic', 'default', { withCredentials: true }) }}", + [], + $newEscape + ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic" data-symfony--ux-turbo--mercure-turbo-stream-with-credentials-value="true"' + : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic" data-symfony--ux-turbo--mercure-turbo-stream-with-credentials-value="true"', + ]; } } diff --git a/src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php b/src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php new file mode 100644 index 00000000000..5d369ab886f --- /dev/null +++ b/src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace src\Turbo\tests\Compiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\UX\Turbo\DependencyInjection\Compiler\RegisterMercureHubsPass; + +final class RegisterMercureHubsPassTest extends TestCase +{ + public function testProcess(): void + { + $pass = new RegisterMercureHubsPass(); + + $container = new ContainerBuilder(); + $container->register('hub') + ->addTag('mercure.hub'); + + $pass->process($container); + + $this->assertTrue($container->has('turbo.mercure.hub.renderer')); + $this->assertTrue($container->has('turbo.mercure.hub.broadcaster')); + } + + public function testProcessWithDefault(): void + { + $pass = new RegisterMercureHubsPass(); + + $container = new ContainerBuilder(); + $container->register('hub1') + ->addTag('mercure.hub'); + + $container->register('default_hub') + ->addTag('mercure.hub', ['default' => true]); + + $pass->process($container); + + $this->assertSame([ + 'transport' => 'default', + ], $container->getDefinition('turbo.mercure.default_hub.renderer')->getTag('turbo.renderer.stream_listen')[1]); + } +} From cb6054ea5ab397abb7816d849638c43e0439da27 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sat, 5 Apr 2025 16:55:13 +0200 Subject: [PATCH 09/58] Update versions to 2.24.0 --- src/Autocomplete/assets/package.json | 2 +- src/Chartjs/assets/package.json | 2 +- src/Cropperjs/assets/package.json | 2 +- src/Dropzone/assets/package.json | 2 +- src/LazyImage/assets/package.json | 2 +- src/LiveComponent/assets/package.json | 2 +- src/Map/assets/package.json | 2 +- src/Map/src/Bridge/Google/assets/package.json | 2 +- src/Map/src/Bridge/Leaflet/assets/package.json | 2 +- src/Notify/assets/package.json | 2 +- src/React/assets/package.json | 2 +- src/StimulusBundle/assets/package.json | 2 +- src/Svelte/assets/package.json | 2 +- src/Swup/assets/package.json | 2 +- src/TogglePassword/assets/package.json | 2 +- src/Translator/assets/package.json | 2 +- src/Turbo/assets/package.json | 2 +- src/Typed/assets/package.json | 2 +- src/Vue/assets/package.json | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Autocomplete/assets/package.json b/src/Autocomplete/assets/package.json index c7761c2e5b6..dcd0b2f6e67 100644 --- a/src/Autocomplete/assets/package.json +++ b/src/Autocomplete/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-autocomplete", "description": "JavaScript Autocomplete functionality for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Chartjs/assets/package.json b/src/Chartjs/assets/package.json index d3ba0749ae8..f5a89743bd9 100644 --- a/src/Chartjs/assets/package.json +++ b/src/Chartjs/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-chartjs", "description": "Chart.js integration for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Cropperjs/assets/package.json b/src/Cropperjs/assets/package.json index 78c15d44df2..5f6182db0dc 100644 --- a/src/Cropperjs/assets/package.json +++ b/src/Cropperjs/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-cropperjs", "description": "Cropper.js integration for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Dropzone/assets/package.json b/src/Dropzone/assets/package.json index ca4d5257b40..65c7c1547f9 100644 --- a/src/Dropzone/assets/package.json +++ b/src/Dropzone/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-dropzone", "description": "File input dropzones for Symfony Forms", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/LazyImage/assets/package.json b/src/LazyImage/assets/package.json index 97a5e4a4f3b..7e4eb3407e5 100644 --- a/src/LazyImage/assets/package.json +++ b/src/LazyImage/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-lazy-image", "description": "Lazy image loader and utilities for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/LiveComponent/assets/package.json b/src/LiveComponent/assets/package.json index 3fa1681b41e..5ee4842a8fb 100644 --- a/src/LiveComponent/assets/package.json +++ b/src/LiveComponent/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-live-component", "description": "Live Component: bring server-side re-rendering & model binding to any element.", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux", "twig", diff --git a/src/Map/assets/package.json b/src/Map/assets/package.json index 9922c9877c9..a2e9a5b3523 100644 --- a/src/Map/assets/package.json +++ b/src/Map/assets/package.json @@ -3,7 +3,7 @@ "description": "Easily embed interactive maps in your Symfony application.", "private": true, "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux", "map", diff --git a/src/Map/src/Bridge/Google/assets/package.json b/src/Map/src/Bridge/Google/assets/package.json index 929c07cd76a..6c0fea00d9b 100644 --- a/src/Map/src/Bridge/Google/assets/package.json +++ b/src/Map/src/Bridge/Google/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-google-map", "description": "GoogleMaps bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux", "google-maps", diff --git a/src/Map/src/Bridge/Leaflet/assets/package.json b/src/Map/src/Bridge/Leaflet/assets/package.json index 1e33c621fee..d89d17d64e4 100644 --- a/src/Map/src/Bridge/Leaflet/assets/package.json +++ b/src/Map/src/Bridge/Leaflet/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-leaflet-map", "description": "Leaflet bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux", "leaflet", diff --git a/src/Notify/assets/package.json b/src/Notify/assets/package.json index 05928771337..f7554fc8bbf 100644 --- a/src/Notify/assets/package.json +++ b/src/Notify/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-notify", "description": "Native notification integration for Symfony using Mercure", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/React/assets/package.json b/src/React/assets/package.json index 2babb566931..3e22b39e719 100644 --- a/src/React/assets/package.json +++ b/src/React/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-react", "description": "Integration of React in Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/StimulusBundle/assets/package.json b/src/StimulusBundle/assets/package.json index fb2b4d54e21..00e3913d50b 100644 --- a/src/StimulusBundle/assets/package.json +++ b/src/StimulusBundle/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/stimulus-bundle", "description": "Integration of @hotwired/stimulus into Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Svelte/assets/package.json b/src/Svelte/assets/package.json index 1b8a2aabeb0..a3766ed8b32 100644 --- a/src/Svelte/assets/package.json +++ b/src/Svelte/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-svelte", "description": "Integration of Svelte in Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Swup/assets/package.json b/src/Swup/assets/package.json index 3d79113c5cb..436c9779a43 100644 --- a/src/Swup/assets/package.json +++ b/src/Swup/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-swup", "description": "Swup integration for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/TogglePassword/assets/package.json b/src/TogglePassword/assets/package.json index 44b9b1cc514..e0defd13cfd 100644 --- a/src/TogglePassword/assets/package.json +++ b/src/TogglePassword/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-toggle-password", "description": "Toggle visibility of password inputs for Symfony Forms", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Translator/assets/package.json b/src/Translator/assets/package.json index 4cb02d648c7..dd84c932127 100644 --- a/src/Translator/assets/package.json +++ b/src/Translator/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-translator", "description": "Symfony Translator for JavaScript", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Turbo/assets/package.json b/src/Turbo/assets/package.json index 1f21ceda28a..29fe49e1716 100644 --- a/src/Turbo/assets/package.json +++ b/src/Turbo/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-turbo", "description": "Hotwire Turbo integration for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux", "turbo", diff --git a/src/Typed/assets/package.json b/src/Typed/assets/package.json index fcce1ec4eb0..5a499f6f013 100644 --- a/src/Typed/assets/package.json +++ b/src/Typed/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-typed", "description": "Typed integration for Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], diff --git a/src/Vue/assets/package.json b/src/Vue/assets/package.json index 9b1d28c312a..edc312e851c 100644 --- a/src/Vue/assets/package.json +++ b/src/Vue/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-vue", "description": "Integration of Vue.js in Symfony", "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], From a25cca52cc7e5d1d695cdd2cd8e8296cb000c5c8 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sat, 5 Apr 2025 11:09:57 -0400 Subject: [PATCH 10/58] remove banner --- ux.symfony.com/templates/_banner.html.twig | 24 ------------------- ux.symfony.com/templates/base.html.twig | 5 ---- .../TwigBundle/Exception/error.html.twig | 2 -- .../templates/ux_packages/chartjs.html.twig | 4 ---- .../ux_packages/live_component.html.twig | 4 ---- .../templates/ux_packages/map.html.twig | 4 ---- .../templates/ux_packages/package.html.twig | 4 ---- .../templates/ux_packages/stimulus.html.twig | 4 ---- .../templates/ux_packages/turbo.html.twig | 4 ---- .../ux_packages/twig_component.html.twig | 4 ---- 10 files changed, 59 deletions(-) delete mode 100644 ux.symfony.com/templates/_banner.html.twig diff --git a/ux.symfony.com/templates/_banner.html.twig b/ux.symfony.com/templates/_banner.html.twig deleted file mode 100644 index e54a457e499..00000000000 --- a/ux.symfony.com/templates/_banner.html.twig +++ /dev/null @@ -1,24 +0,0 @@ - diff --git a/ux.symfony.com/templates/base.html.twig b/ux.symfony.com/templates/base.html.twig index 61fbfd2494b..33b40825631 100644 --- a/ux.symfony.com/templates/base.html.twig +++ b/ux.symfony.com/templates/base.html.twig @@ -38,11 +38,6 @@ {% endblock %} - - {% block banner %} - {{ include('_banner.html.twig') }} - {% endblock %} - {% block body %}
    {% block header %} diff --git a/ux.symfony.com/templates/bundles/TwigBundle/Exception/error.html.twig b/ux.symfony.com/templates/bundles/TwigBundle/Exception/error.html.twig index 87475bc994b..53da024a671 100644 --- a/ux.symfony.com/templates/bundles/TwigBundle/Exception/error.html.twig +++ b/ux.symfony.com/templates/bundles/TwigBundle/Exception/error.html.twig @@ -4,8 +4,6 @@ {% block meta %}{% endblock %} -{% block banner %}{% endblock %} - {% block main %}
    diff --git a/ux.symfony.com/templates/ux_packages/chartjs.html.twig b/ux.symfony.com/templates/ux_packages/chartjs.html.twig index b13f1e56366..746bde80dc1 100644 --- a/ux.symfony.com/templates/ux_packages/chartjs.html.twig +++ b/ux.symfony.com/templates/ux_packages/chartjs.html.twig @@ -1,9 +1,5 @@ {% extends 'ux_packages/package.html.twig' %} -{% block banner %} - {{ include('_banner.html.twig', {color_back: '#165514'}) }} -{% endblock %} - {% block package_header %} {% component PackageHeader with { package: 'chartjs', diff --git a/ux.symfony.com/templates/ux_packages/live_component.html.twig b/ux.symfony.com/templates/ux_packages/live_component.html.twig index 20b2cfbc817..b8eb5a286a8 100644 --- a/ux.symfony.com/templates/ux_packages/live_component.html.twig +++ b/ux.symfony.com/templates/ux_packages/live_component.html.twig @@ -1,9 +1,5 @@ {% extends 'ux_packages/package.html.twig' %} -{% block banner %} - {{ include('_banner.html.twig', {color_back: '#854e0d'}) }} -{% endblock %} - {% block package_header %} {% component PackageHeader with { package: 'live-component', diff --git a/ux.symfony.com/templates/ux_packages/map.html.twig b/ux.symfony.com/templates/ux_packages/map.html.twig index 7273ea33144..8f7afe5edf6 100644 --- a/ux.symfony.com/templates/ux_packages/map.html.twig +++ b/ux.symfony.com/templates/ux_packages/map.html.twig @@ -1,9 +1,5 @@ {% extends 'ux_packages/package.html.twig' %} -{% block banner %} - {{ include('_banner.html.twig', {color_back: '#165514'}) }} -{% endblock %} - {% set package = 'map'|ux_package %} {% block package_header %} diff --git a/ux.symfony.com/templates/ux_packages/package.html.twig b/ux.symfony.com/templates/ux_packages/package.html.twig index 7b1c4b797b6..709debc3be6 100644 --- a/ux.symfony.com/templates/ux_packages/package.html.twig +++ b/ux.symfony.com/templates/ux_packages/package.html.twig @@ -17,10 +17,6 @@ } } %} -{% block banner %} - {{ include('_banner.html.twig', {color_back: '#222'}) }} -{% endblock %} - {% block header %} {{ include('_header.html.twig', { theme: 'white' diff --git a/ux.symfony.com/templates/ux_packages/stimulus.html.twig b/ux.symfony.com/templates/ux_packages/stimulus.html.twig index 0d3b9c65ee5..7b11dc69cd8 100644 --- a/ux.symfony.com/templates/ux_packages/stimulus.html.twig +++ b/ux.symfony.com/templates/ux_packages/stimulus.html.twig @@ -1,9 +1,5 @@ {% extends 'ux_packages/package.html.twig' %} -{% block banner %} - {{ include('_banner.html.twig', {color_back: '#18543e'}) }} -{% endblock %} - {% block package_header %} Date: Sat, 5 Apr 2025 20:52:49 +0200 Subject: [PATCH 11/58] [CI] Fix version bump for npm packages --- .github/workflows/release-on-npm.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-on-npm.yaml b/.github/workflows/release-on-npm.yaml index 2210a69917c..f2913b6d2de 100644 --- a/.github/workflows/release-on-npm.yaml +++ b/.github/workflows/release-on-npm.yaml @@ -32,7 +32,7 @@ jobs: - run: yarn --immutable - name: Update version of JS packages - run: yarn workspaces foreach -A version --immediate "${{ env.VERSION }}" + run: yarn workspaces foreach -pA exec "npm version ${{ env.VERSION }} --no-git-tag-version --no-workspaces-update" - name: Commit changes run: | From 9af4c3e870a118ac397e7ed35528017a3ac28ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Thu, 10 Apr 2025 01:39:39 +0200 Subject: [PATCH 12/58] [CI] Use git diff instead of git status The check failed here because file changed .. but with no content change. --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5ad2e12fe0c..9ea4c3fbc06 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -41,7 +41,7 @@ jobs: - name: Check if JS dist files are current run: | - if [[ -n $(git status --porcelain) ]]; then + if ! git diff --quiet; then echo "The Git workspace is unclean! Changes detected:" git status --porcelain git diff From f42d00fa308fbe7739ec36cc686db6baeeb0f5de Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 11 Apr 2025 10:37:22 +0200 Subject: [PATCH 13/58] Add .editorconfig, enforce LF line-ending style --- .editorconfig | 13 +++++ .gitattributes | 1 + src/Autocomplete/assets/dist/controller.js | 52 +++++++++---------- .../assets/test/fixtures/MyComponent.svelte | 12 ++--- 4 files changed, 46 insertions(+), 32 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..c97a91b6fa5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +; top-most EditorConfig file +root = true + +; Unix-style newlines +[*] +charset = utf-8 +end_of_line = LF +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{php,html,twig}] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes index af3ad128122..097cb99cf7e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +* text=auto eol=lf /.yarn/** linguist-vendored /.yarn/releases/* binary /.yarn/plugins/**/* binary diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index e021770d8b5..1f2363100e9 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -1,32 +1,32 @@ import { Controller } from '@hotwired/stimulus'; import TomSelect from 'tom-select'; -/****************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ -/* global Reflect, Promise, SuppressedError, Symbol */ - - -function __classPrivateFieldGet(receiver, state, kind, f) { - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); - return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); -} - -typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { - var e = new Error(message); - return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ +/* global Reflect, Promise, SuppressedError, Symbol */ + + +function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +} + +typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect; diff --git a/src/Svelte/assets/test/fixtures/MyComponent.svelte b/src/Svelte/assets/test/fixtures/MyComponent.svelte index c5f9d4eb515..2823f5c2163 100644 --- a/src/Svelte/assets/test/fixtures/MyComponent.svelte +++ b/src/Svelte/assets/test/fixtures/MyComponent.svelte @@ -1,8 +1,8 @@ - - -
    -
    Hello {name}
    -
    \ No newline at end of file + + +
    +
    Hello {name}
    +
    From b68a52fa7014e43efd5aea811dd9f16e6628b010 Mon Sep 17 00:00:00 2001 From: seb-jean Date: Fri, 11 Apr 2025 11:18:14 +0200 Subject: [PATCH 14/58] [Turbo] Minor documentation syntax fixes --- src/Turbo/doc/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst index d215aaaa8df..e198d70647b 100644 --- a/src/Turbo/doc/index.rst +++ b/src/Turbo/doc/index.rst @@ -901,7 +901,7 @@ The ``Broadcast`` attribute comes with a set of handy options: - ``template`` (``string``): Twig template to render (see above) The ``Broadcast`` attribute can be repeated (e.g. you can have multiple -`#[Broadcast]`. This is convenient to render several templates associated with +``#[Broadcast]``. This is convenient to render several templates associated with their own topics for the same change (e.g. the same data is rendered in different way in the list and in the detail pages). @@ -914,7 +914,7 @@ are supported: - ``sse_retry`` (``int``): ``retry`` field of the SSE The Mercure broadcaster also supports `Expression Language`_ in topics -by starting with `@=`. +by starting with ``@=``. Example:: From 898953e7fe4e8879a835266b5ff52647837a2718 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 7 Apr 2025 21:25:36 +0200 Subject: [PATCH 15/58] [CI] Adjust tags to "v2.*.*" for NPM auto-releases --- .github/workflows/release-on-npm.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-on-npm.yaml b/.github/workflows/release-on-npm.yaml index f2913b6d2de..397575391b0 100644 --- a/.github/workflows/release-on-npm.yaml +++ b/.github/workflows/release-on-npm.yaml @@ -3,7 +3,7 @@ name: Release on NPM on: push: tags: - - 'v*.*.*' + - 'v2.*.*' jobs: release: From 62978fac0bd84c702d63825aaa1ac8340b128b3d Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sat, 5 Apr 2025 11:48:45 -0400 Subject: [PATCH 16/58] [Icons] improve DX when `symfony/http-client` is not installed - throw exception if http-client is not available - always enable iconify, iconify commands and on-demand registry - display "potential" missing icons to `ux:icons:warm-cache` --- src/Icons/CHANGELOG.md | 4 ++ src/Icons/config/iconify.php | 56 ------------------- src/Icons/config/services.php | 39 +++++++++++++ src/Icons/doc/index.rst | 24 ++++++++ src/Icons/src/Command/WarmCacheCommand.php | 5 ++ .../DependencyInjection/UXIconsExtension.php | 27 ++++----- .../HttpClientNotInstalledException.php | 21 +++++++ src/Icons/src/IconCacheWarmer.php | 8 +-- src/Icons/src/Iconify.php | 31 ++++++---- src/Icons/src/Registry/ChainIconRegistry.php | 10 +++- .../src/Registry/IconifyOnDemandRegistry.php | 7 ++- src/Icons/tests/Unit/IconifyTest.php | 18 +++--- 12 files changed, 152 insertions(+), 98 deletions(-) delete mode 100644 src/Icons/config/iconify.php create mode 100644 src/Icons/src/Exception/HttpClientNotInstalledException.php diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md index 48a8ba77b5a..52b4fb26f97 100644 --- a/src/Icons/CHANGELOG.md +++ b/src/Icons/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.25.0 + +- Improve DX when `symfony/http-client` is not installed. + ## 2.24.0 - Add `xmlns` attribute to icons downloaded with Iconify, to correctly render icons browser as an external file, in SVG editors, and in files explorers or text editors previews. diff --git a/src/Icons/config/iconify.php b/src/Icons/config/iconify.php deleted file mode 100644 index 52aa705a887..00000000000 --- a/src/Icons/config/iconify.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\DependencyInjection\Loader\Configurator; - -use Symfony\UX\Icons\Command\ImportIconCommand; -use Symfony\UX\Icons\Command\LockIconsCommand; -use Symfony\UX\Icons\Command\SearchIconCommand; -use Symfony\UX\Icons\Iconify; -use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry; - -return static function (ContainerConfigurator $container): void { - $container->services() - ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class) - ->args([ - service('.ux_icons.iconify'), - ]) - ->tag('ux_icons.registry') - - ->set('.ux_icons.iconify', Iconify::class) - ->args([ - service('.ux_icons.cache'), - abstract_arg('endpoint'), - service('http_client')->nullOnInvalid(), - ]) - - ->set('.ux_icons.command.import', ImportIconCommand::class) - ->args([ - service('.ux_icons.iconify'), - service('.ux_icons.local_svg_icon_registry'), - ]) - ->tag('console.command') - - ->set('.ux_icons.command.lock', LockIconsCommand::class) - ->args([ - service('.ux_icons.iconify'), - service('.ux_icons.local_svg_icon_registry'), - service('.ux_icons.icon_finder'), - ]) - ->tag('console.command') - - ->set('.ux_icons.command.search', SearchIconCommand::class) - ->args([ - service('.ux_icons.iconify'), - ]) - ->tag('console.command') - ; -}; diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php index 537c0cbb892..4b29a381f7a 100644 --- a/src/Icons/config/services.php +++ b/src/Icons/config/services.php @@ -11,12 +11,17 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\UX\Icons\Command\ImportIconCommand; +use Symfony\UX\Icons\Command\LockIconsCommand; +use Symfony\UX\Icons\Command\SearchIconCommand; use Symfony\UX\Icons\Command\WarmCacheCommand; use Symfony\UX\Icons\IconCacheWarmer; +use Symfony\UX\Icons\Iconify; use Symfony\UX\Icons\IconRenderer; use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Icons\Registry\CacheIconRegistry; use Symfony\UX\Icons\Registry\ChainIconRegistry; +use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry; use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; use Symfony\UX\Icons\Twig\IconFinder; use Symfony\UX\Icons\Twig\UXIconExtension; @@ -86,5 +91,39 @@ service('.ux_icons.cache_warmer'), ]) ->tag('console.command') + + ->set('.ux_icons.iconify', Iconify::class) + ->args([ + service('.ux_icons.cache'), + abstract_arg('endpoint'), + service('http_client')->nullOnInvalid(), + ]) + + ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class) + ->args([ + service('.ux_icons.iconify'), + ]) + ->tag('ux_icons.registry', ['priority' => -10]) + + ->set('.ux_icons.command.import', ImportIconCommand::class) + ->args([ + service('.ux_icons.iconify'), + service('.ux_icons.local_svg_icon_registry'), + ]) + ->tag('console.command') + + ->set('.ux_icons.command.lock', LockIconsCommand::class) + ->args([ + service('.ux_icons.iconify'), + service('.ux_icons.local_svg_icon_registry'), + service('.ux_icons.icon_finder'), + ]) + ->tag('console.command') + + ->set('.ux_icons.command.search', SearchIconCommand::class) + ->args([ + service('.ux_icons.iconify'), + ]) + ->tag('console.command') ; }; diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index 5700e12a7b5..607e0160354 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -286,6 +286,18 @@ the report to overwrite existing icons by using the ``--force`` option: $ php bin/console ux:icons:lock --force +.. caution:: + + The process to find icons to lock in your Twig templates is imperfect. It + looks for any string that matches the pattern ``something:something`` so + it's probable there will be false positives. This command should not be used + to audit the icons in your templates in an automated way. Add ``-v`` see + *potential* invalid icons: + + .. code-block:: terminal + + $ php bin/console ux:icons:lock -v + Rendering Icons --------------- @@ -472,6 +484,18 @@ In production, you can pre-warm the cache by running the following command: This command looks in all your Twig templates for ``ux_icon()`` calls and ```` tags and caches the icons it finds. +.. caution:: + + The process to find icons to cache in your Twig templates is imperfect. It + looks for any string that matches the pattern ``something:something`` so + it's probable there will be false positives. This command should not be used + to audit the icons in your templates in an automated way. Add ``-v`` see + *potential* invalid icons: + + .. code-block:: terminal + + $ php bin/console ux:icons:warm-cache -v + .. caution:: Icons that have a name built dynamically will not be cached. It's advised to diff --git a/src/Icons/src/Command/WarmCacheCommand.php b/src/Icons/src/Command/WarmCacheCommand.php index 1451a7cfc89..8263183c700 100644 --- a/src/Icons/src/Command/WarmCacheCommand.php +++ b/src/Icons/src/Command/WarmCacheCommand.php @@ -45,6 +45,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeln(\sprintf(' Warmed icon %s.', $name)); } }, + onFailure: function (string $name, \Exception $e) use ($io) { + if ($io->isVerbose()) { + $io->writeln(\sprintf(' Failed to warm (potential) icon %s.', $name)); + } + } ); $io->success('Icon cache warmed.'); diff --git a/src/Icons/src/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php index 85672aebeec..b93240911cb 100644 --- a/src/Icons/src/DependencyInjection/UXIconsExtension.php +++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php @@ -18,7 +18,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; -use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\UX\Icons\Iconify; /** @@ -87,7 +86,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->arrayNode('iconify') ->info('Configuration for the remote icon service.') - ->{interface_exists(HttpClientInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->canBeDisabled() ->children() ->booleanNode('on_demand') ->info('Whether to download icons "on demand".') @@ -164,26 +163,24 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container ->setArgument(1, $mergedConfig['ignore_not_found']) ; - if ($mergedConfig['iconify']['enabled']) { - $loader->load('iconify.php'); + $container->getDefinition('.ux_icons.iconify') + ->setArgument(1, $mergedConfig['iconify']['endpoint']); - $container->getDefinition('.ux_icons.iconify') - ->setArgument(1, $mergedConfig['iconify']['endpoint']); + $container->getDefinition('.ux_icons.iconify_on_demand_registry') + ->setArgument(1, $iconSetAliases); - $container->getDefinition('.ux_icons.iconify_on_demand_registry') - ->setArgument(1, $iconSetAliases); + $container->getDefinition('.ux_icons.command.lock') + ->setArgument(3, $mergedConfig['aliases']) + ->setArgument(4, $iconSetAliases); - $container->getDefinition('.ux_icons.command.lock') - ->setArgument(3, $mergedConfig['aliases']) - ->setArgument(4, $iconSetAliases); - - if (!$mergedConfig['iconify']['on_demand']) { - $container->removeDefinition('.ux_icons.iconify_on_demand_registry'); - } + if (!$mergedConfig['iconify']['on_demand'] || !$mergedConfig['iconify']['enabled']) { + $container->removeDefinition('.ux_icons.iconify_on_demand_registry'); } if (!$container->getParameter('kernel.debug')) { $container->removeDefinition('.ux_icons.command.import'); + $container->removeDefinition('.ux_icons.command.search'); + $container->removeDefinition('.ux_icons.command.lock'); } } } diff --git a/src/Icons/src/Exception/HttpClientNotInstalledException.php b/src/Icons/src/Exception/HttpClientNotInstalledException.php new file mode 100644 index 00000000000..eb624ade512 --- /dev/null +++ b/src/Icons/src/Exception/HttpClientNotInstalledException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Exception; + +/** + * @author Kevin Bond + * + * @internal + */ +final class HttpClientNotInstalledException extends \LogicException +{ +} diff --git a/src/Icons/src/IconCacheWarmer.php b/src/Icons/src/IconCacheWarmer.php index 04215ae713f..2a63ad8499d 100644 --- a/src/Icons/src/IconCacheWarmer.php +++ b/src/Icons/src/IconCacheWarmer.php @@ -27,8 +27,8 @@ public function __construct(private CacheIconRegistry $registry, private IconFin } /** - * @param callable(string,Icon):void|null $onSuccess - * @param callable(string):void|null $onFailure + * @param callable(string,Icon):void|null $onSuccess + * @param callable(string,\Exception):void|null $onFailure */ public function warm(?callable $onSuccess = null, ?callable $onFailure = null): void { @@ -40,8 +40,8 @@ public function warm(?callable $onSuccess = null, ?callable $onFailure = null): $icon = $this->registry->get($name, refresh: true); $onSuccess($name, $icon); - } catch (IconNotFoundException) { - $onFailure($name); + } catch (IconNotFoundException $e) { + $onFailure($name, $e); } } } diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php index 55c5051d335..7f7db1ea552 100644 --- a/src/Icons/src/Iconify.php +++ b/src/Icons/src/Iconify.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\UX\Icons\Exception\HttpClientNotInstalledException; use Symfony\UX\Icons\Exception\IconNotFoundException; /** @@ -39,15 +40,10 @@ final class Iconify public function __construct( private CacheInterface $cache, - string $endpoint = self::API_ENDPOINT, - ?HttpClientInterface $http = null, + private string $endpoint = self::API_ENDPOINT, + private ?HttpClientInterface $httpClient = null, ?int $maxIconsQueryLength = null, ) { - if (!class_exists(HttpClient::class)) { - throw new \LogicException('You must install "symfony/http-client" to use Iconify. Try running "composer require symfony/http-client".'); - } - - $this->http = ScopingHttpClient::forBaseUri($http ?? HttpClient::create(), $endpoint); $this->maxIconsQueryLength = min(self::MAX_ICONS_QUERY_LENGTH, $maxIconsQueryLength ?? self::MAX_ICONS_QUERY_LENGTH); } @@ -62,7 +58,7 @@ public function fetchIcon(string $prefix, string $name): Icon throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); } - $response = $this->http->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name)); + $response = $this->http()->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name)); if (200 !== $response->getStatusCode()) { throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); @@ -112,7 +108,7 @@ public function fetchIcons(string $prefix, array $names): array throw new \InvalidArgumentException('The query string is too long.'); } - $response = $this->http->request('GET', \sprintf('/%s.json', $prefix), [ + $response = $this->http()->request('GET', \sprintf('/%s.json', $prefix), [ 'headers' => [ 'Accept' => 'application/json', ], @@ -158,7 +154,7 @@ public function getIconSets(): array public function searchIcons(string $prefix, string $query) { - $response = $this->http->request('GET', '/search', [ + $response = $this->http()->request('GET', '/search', [ 'query' => [ 'query' => $query, 'prefix' => $prefix, @@ -205,9 +201,22 @@ public function chunk(string $prefix, array $names): iterable private function sets(): \ArrayObject { return $this->sets ??= $this->cache->get('iconify-sets', function () { - $response = $this->http->request('GET', '/collections'); + $response = $this->http()->request('GET', '/collections'); return new \ArrayObject($response->toArray()); }); } + + private function http(): HttpClientInterface + { + if (isset($this->http)) { + return $this->http; + } + + if (!class_exists(HttpClient::class)) { + throw new HttpClientNotInstalledException('You must install "symfony/http-client" to use icons from ux.symfony.com/icons. Try running "composer require symfony/http-client".'); + } + + return $this->http = ScopingHttpClient::forBaseUri($this->httpClient ?? HttpClient::create(), $this->endpoint); + } } diff --git a/src/Icons/src/Registry/ChainIconRegistry.php b/src/Icons/src/Registry/ChainIconRegistry.php index c6e882cff59..d476d25c056 100644 --- a/src/Icons/src/Registry/ChainIconRegistry.php +++ b/src/Icons/src/Registry/ChainIconRegistry.php @@ -34,10 +34,16 @@ public function get(string $name): Icon foreach ($this->registries as $registry) { try { return $registry->get($name); - } catch (IconNotFoundException) { + } catch (IconNotFoundException $e) { } } - throw new IconNotFoundException(\sprintf('Icon "%s" not found.', $name)); + $message = \sprintf('Icon "%s" not found.', $name); + + if (isset($e)) { + $message .= " {$e->getMessage()}"; + } + + throw new IconNotFoundException($message, previous: $e ?? null); } } diff --git a/src/Icons/src/Registry/IconifyOnDemandRegistry.php b/src/Icons/src/Registry/IconifyOnDemandRegistry.php index 5931854ca75..60c1b591cc4 100644 --- a/src/Icons/src/Registry/IconifyOnDemandRegistry.php +++ b/src/Icons/src/Registry/IconifyOnDemandRegistry.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Icons\Registry; +use Symfony\UX\Icons\Exception\HttpClientNotInstalledException; use Symfony\UX\Icons\Exception\IconNotFoundException; use Symfony\UX\Icons\Icon; use Symfony\UX\Icons\Iconify; @@ -36,6 +37,10 @@ public function get(string $name): Icon } [$prefix, $icon] = $parts; - return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon); + try { + return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon); + } catch (HttpClientNotInstalledException $e) { + throw new IconNotFoundException($e->getMessage()); + } } } diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php index cec6172cb7d..8516568b028 100644 --- a/src/Icons/tests/Unit/IconifyTest.php +++ b/src/Icons/tests/Unit/IconifyTest.php @@ -29,7 +29,7 @@ public function testFetchIcon(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -55,7 +55,7 @@ public function testFetchIconByAlias(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -96,7 +96,7 @@ public function testFetchIconUsesIconsetViewBoxHeight(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [ 'height' => 17, @@ -124,7 +124,7 @@ public function testFetchIconThrowsWhenViewBoxCannotBeComputed(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -149,7 +149,7 @@ public function testFetchIconThrowsWhenStatusCodeNot200(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -168,7 +168,7 @@ public function testFetchIcons(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -199,7 +199,7 @@ public function testFetchIconsByAliases(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'mdi' => [], ]), @@ -239,7 +239,7 @@ public function testFetchIconsThrowsWithInvalidIconNames(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -256,7 +256,7 @@ public function testFetchIconsThrowsWithTooManyIcons(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), From 54da4dd7d57a7190362e8f64d4fb40236cd3e854 Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 24 Mar 2025 10:31:53 +0200 Subject: [PATCH 17/58] [LiveComponent] Add uid support for hydration --- src/LiveComponent/CHANGELOG.md | 3 +- src/LiveComponent/composer.json | 1 + .../src/LiveComponentHydrator.php | 13 ++++++++ .../Integration/LiveComponentHydratorTest.php | 33 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index c7b7458ef52..02366f6ebf6 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,7 +2,8 @@ ## 2.25.0 -- Add property name as second paramter to LiveProp modifier callback +- Add support for [Symfony UID](https://symfony.com/doc/current/components/uid.html) hydration/dehydration +- Add property name as second paramter to LiveProp modifier callback ## 2.23.0 diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index ffa19376e42..1cfdf9b4541 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -50,6 +50,7 @@ "symfony/security-bundle": "^5.4|^6.0|^7.0", "symfony/serializer": "^5.4|^6.0|^7.0", "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0", "symfony/validator": "^5.4|^6.0|^7.0", "zenstruck/browser": "^1.2.0", "zenstruck/foundry": "^2.0" diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index 95f4bf91f9c..ce55e9f873b 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -22,6 +22,7 @@ use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Uid\AbstractUid; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Exception\HydrationException; @@ -505,6 +506,10 @@ private function dehydrateObjectValue(object $value, string $classType, ?string return $value->value; } + if ($value instanceof AbstractUid) { + return (string) $value; + } + foreach ($this->hydrationExtensions as $extension) { if ($extension->supports($classType)) { return $extension->dehydrate($value); @@ -553,6 +558,14 @@ private function hydrateObjectValue(mixed $value, string $className, bool $allow return new $className($value); } + if (is_a($className, AbstractUid::class, true)) { + if (!\is_string($value)) { + throw new BadRequestHttpException(\sprintf('The model path "%s" was sent an invalid data type "%s" for a uuid.', $propertyPathForError, get_debug_type($value))); + } + + return $className::fromString($value); + } + foreach ($this->hydrationExtensions as $extension) { if ($extension->supports($className)) { return $extension->hydrate($value, $className); diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php index 004a6dff6a1..b9e1f9228e3 100644 --- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php @@ -13,6 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV4; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Exception\HydrationException; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; @@ -1372,6 +1375,36 @@ public function modifyDateProp(LiveProp $prop): LiveProp }) ; }]; + + yield 'Uuid: (de)hydrates correctly' => [function () { + $uuid = new UuidV4('ffdb229c-13e6-4bc4-939e-c8e73958104c'); + + return HydrationTest::create(new class { + #[LiveProp] + public Uuid $id; + }) + ->mountWith(['id' => $uuid]) + ->assertDehydratesTo(['id' => 'ffdb229c-13e6-4bc4-939e-c8e73958104c']) + ->assertObjectAfterHydration(function (object $object) { + self::assertEquals(new UuidV4('ffdb229c-13e6-4bc4-939e-c8e73958104c'), $object->id); + }) + ; + }]; + + yield 'Ulid: (de)hydrates correctly' => [function () { + $uuid = new Ulid('01AN4Z07BY79KA1307SR9X4MV3'); + + return HydrationTest::create(new class { + #[LiveProp] + public Ulid $id; + }) + ->mountWith(['id' => $uuid]) + ->assertDehydratesTo(['id' => '01AN4Z07BY79KA1307SR9X4MV3']) + ->assertObjectAfterHydration(function (object $object) { + self::assertEquals(new Ulid('01AN4Z07BY79KA1307SR9X4MV3'), $object->id); + }) + ; + }]; } public function testHydrationWithInvalidDate(): void From 67f93899569bc0897a81148055b9efd4fdcad774 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 15 Apr 2025 09:45:09 +0200 Subject: [PATCH 18/58] [Map] Downgrade PHP requirement from 8.3 to 8.1 --- .github/workflows/test.yaml | 6 ----- src/Map/CHANGELOG.md | 10 +++++--- src/Map/composer.json | 4 ++-- src/Map/src/Bridge/Google/CHANGELOG.md | 6 ++++- src/Map/src/Bridge/Google/composer.json | 7 +++--- src/Map/src/Bridge/Google/phpunit.xml.dist | 3 +-- .../src/Option/FullscreenControlOptions.php | 4 ++-- .../src/Option/MapTypeControlOptions.php | 8 +++---- .../src/Option/StreetViewControlOptions.php | 4 ++-- .../Google/src/Option/ZoomControlOptions.php | 4 ++-- .../Google/src/Renderer/GoogleRenderer.php | 23 +++++++++---------- src/Map/src/Bridge/Leaflet/CHANGELOG.md | 8 +++++-- src/Map/src/Bridge/Leaflet/composer.json | 7 +++--- src/Map/src/Bridge/Leaflet/phpunit.xml.dist | 3 +-- .../Bridge/Leaflet/src/Option/TileLayer.php | 8 +++---- .../Leaflet/src/Renderer/LeafletRenderer.php | 2 +- src/Map/src/Distance/DistanceCalculator.php | 6 ++--- .../Distance/HaversineDistanceCalculator.php | 2 +- .../SphericalCosineDistanceCalculator.php | 2 +- .../Distance/VincentyDistanceCalculator.php | 2 +- src/Map/src/Icon/UxIconRenderer.php | 4 ++-- src/Map/src/InfoWindow.php | 14 +++++------ src/Map/src/MapOptionsNormalizer.php | 5 +++- src/Map/src/Marker.php | 14 +++++------ src/Map/src/Point.php | 6 ++--- src/Map/src/Polygon.php | 12 +++++----- src/Map/src/Polyline.php | 12 +++++----- src/Map/src/Renderer/AbstractRenderer.php | 6 ++--- src/Map/src/Renderer/Dsn.php | 12 +++++----- src/Map/src/Renderer/NullRenderer.php | 4 ++-- src/Map/src/Renderer/NullRendererFactory.php | 4 ++-- src/Map/src/Renderer/Renderer.php | 4 ++-- src/Map/tests/DummyOptions.php | 6 ++--- src/Map/tests/Twig/MapExtensionTest.php | 5 ++-- 34 files changed, 118 insertions(+), 109 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9ea4c3fbc06..a17aa41e180 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -84,12 +84,6 @@ jobs: minimum-stability: 'dev' - php-version: '8.3' minimum-stability: 'dev' - - component: Map # does not support PHP 8.1 - php-version: '8.1' - - component: Map/src/Bridge/Google # does not support PHP 8.1 - php-version: '8.1' - - component: Map/src/Bridge/Leaflet # does not support PHP 8.1 - php-version: '8.1' - component: Swup # has no tests - component: Turbo # has its own workflow (test-turbo.yml) - component: Typed # has no tests diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index b75836888fe..dfb22e55ceb 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.25 + +- Downgrade PHP requirement from 8.3 to 8.1 + ## 2.24 - Installing the package in a Symfony app using Flex won't add the `@symfony/ux-map` dependency to the `package.json` file anymore. @@ -26,13 +30,13 @@ ## 2.20 -- Deprecate `render_map` Twig function (will be removed in 2.21). Use +- Deprecate `render_map` Twig function (will be removed in 2.21). Use `ux_map` or the `` Twig component instead. -- Add `ux_map` Twig function (replaces `render_map` with a more flexible +- Add `ux_map` Twig function (replaces `render_map` with a more flexible interface) - Add `` Twig component - The importmap entry `@symfony/ux-map/abstract-map-controller` can be removed - from your importmap, it is no longer needed. + from your importmap, it is no longer needed. - Add `Polygon` support ## 2.19 diff --git a/src/Map/composer.json b/src/Map/composer.json index 509983e26f6..c08fcb87f18 100644 --- a/src/Map/composer.json +++ b/src/Map/composer.json @@ -32,13 +32,13 @@ } }, "require": { - "php": ">=8.3", + "php": ">=8.1", "symfony/stimulus-bundle": "^2.18.1" }, "require-dev": { "symfony/asset-mapper": "^6.4|^7.0", "symfony/framework-bundle": "^6.4|^7.0", - "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/phpunit-bridge": "^7.2", "symfony/twig-bundle": "^6.4|^7.0", "symfony/ux-twig-component": "^2.18", "symfony/ux-icons": "^2.18" diff --git a/src/Map/src/Bridge/Google/CHANGELOG.md b/src/Map/src/Bridge/Google/CHANGELOG.md index 48478b9806b..bfaea94609e 100644 --- a/src/Map/src/Bridge/Google/CHANGELOG.md +++ b/src/Map/src/Bridge/Google/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.25 + +- Downgrade PHP requirement from 8.3 to 8.1 + ## 2.22 - Add support for configuring a default Map ID @@ -10,7 +14,7 @@ ### BC Breaks -- Renamed importmap entry `@symfony/ux-google-map/map-controller` to `@symfony/ux-google-map`, +- Renamed importmap entry `@symfony/ux-google-map/map-controller` to `@symfony/ux-google-map`, you will need to update your importmap. ## 2.19 diff --git a/src/Map/src/Bridge/Google/composer.json b/src/Map/src/Bridge/Google/composer.json index 00f77046a73..7889f360787 100644 --- a/src/Map/src/Bridge/Google/composer.json +++ b/src/Map/src/Bridge/Google/composer.json @@ -16,14 +16,15 @@ } ], "require": { - "php": ">=8.3", + "php": ">=8.1", "symfony/stimulus-bundle": "^2.18.1", "symfony/ux-map": "^2.19" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/phpunit-bridge": "^7.2", "symfony/ux-icons": "^2.18", - "spatie/phpunit-snapshot-assertions": "^5.1.8" + "spatie/phpunit-snapshot-assertions": "^4.2.17", + "phpunit/phpunit": "^9.6.22" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Google\\": "src/" }, diff --git a/src/Map/src/Bridge/Google/phpunit.xml.dist b/src/Map/src/Bridge/Google/phpunit.xml.dist index 4049eb2ea15..751314ecb8e 100644 --- a/src/Map/src/Bridge/Google/phpunit.xml.dist +++ b/src/Map/src/Bridge/Google/phpunit.xml.dist @@ -12,11 +12,10 @@ ./src - + - diff --git a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php index ed58a3c0051..ab3df9d4960 100644 --- a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php @@ -18,10 +18,10 @@ * * @author Hugo Alliaume */ -final readonly class FullscreenControlOptions +final class FullscreenControlOptions { public function __construct( - private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, + private readonly ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, ) { } diff --git a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php index 11dfe8279fb..3cc28e3662c 100644 --- a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php @@ -18,15 +18,15 @@ * * @author Hugo Alliaume */ -final readonly class MapTypeControlOptions +final class MapTypeControlOptions { /** * @param array<'hybrid'|'roadmap'|'satellite'|'terrain'|string> $mapTypeIds */ public function __construct( - private array $mapTypeIds = [], - private ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, - private MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, + private readonly array $mapTypeIds = [], + private readonly ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, + private readonly MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, ) { } diff --git a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php index 897c7467969..2fa9a89c1f6 100644 --- a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php @@ -18,10 +18,10 @@ * * @author Hugo Alliaume */ -final readonly class StreetViewControlOptions +final class StreetViewControlOptions { public function __construct( - private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + private readonly ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, ) { } diff --git a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php index 644ba79f536..b669e5b53c5 100644 --- a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php @@ -18,10 +18,10 @@ * * @author Hugo Alliaume */ -final readonly class ZoomControlOptions +final class ZoomControlOptions { public function __construct( - private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + private readonly ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, ) { } diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php index 1077e26a466..331137ed4a5 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php @@ -22,7 +22,7 @@ * * @internal */ -final readonly class GoogleRenderer extends AbstractRenderer +final class GoogleRenderer extends AbstractRenderer { /** * Parameters are based from https://googlemaps.github.io/js-api-loader/interfaces/LoaderOptions.html documentation. @@ -30,20 +30,19 @@ public function __construct( StimulusHelper $stimulusHelper, UxIconRenderer $uxIconRenderer, - #[\SensitiveParameter] - private string $apiKey, - private ?string $id = null, - private ?string $language = null, - private ?string $region = null, - private ?string $nonce = null, - private ?int $retries = null, - private ?string $url = null, - private ?string $version = null, + #[\SensitiveParameter] private readonly string $apiKey, + private readonly ?string $id = null, + private readonly ?string $language = null, + private readonly ?string $region = null, + private readonly ?string $nonce = null, + private readonly ?int $retries = null, + private readonly ?string $url = null, + private readonly ?string $version = null, /** * @var array<'core'|'maps'|'places'|'geocoding'|'routes'|'marker'|'geometry'|'elevation'|'streetView'|'journeySharing'|'drawing'|'visualization'> */ - private array $libraries = [], - private ?string $defaultMapId = null, + private readonly array $libraries = [], + private readonly ?string $defaultMapId = null, ) { parent::__construct($stimulusHelper, $uxIconRenderer); } diff --git a/src/Map/src/Bridge/Leaflet/CHANGELOG.md b/src/Map/src/Bridge/Leaflet/CHANGELOG.md index e380bd8e66c..3d6ee0fc587 100644 --- a/src/Map/src/Bridge/Leaflet/CHANGELOG.md +++ b/src/Map/src/Bridge/Leaflet/CHANGELOG.md @@ -1,11 +1,15 @@ # CHANGELOG +## 2.25 + +- Downgrade PHP requirement from 8.3 to 8.1 + ## 2.20 ### BC Breaks -- Renamed importmap entry `@symfony/ux-leaflet-map/map-controller` to `@symfony/ux-leaflet-map`, - you will need to update your importmap. +- Renamed importmap entry `@symfony/ux-leaflet-map/map-controller` to `@symfony/ux-leaflet-map`, + you will need to update your importmap. ## 2.19 diff --git a/src/Map/src/Bridge/Leaflet/composer.json b/src/Map/src/Bridge/Leaflet/composer.json index 3d5b74b14a6..f33e00cd48a 100644 --- a/src/Map/src/Bridge/Leaflet/composer.json +++ b/src/Map/src/Bridge/Leaflet/composer.json @@ -16,14 +16,15 @@ } ], "require": { - "php": ">=8.3", + "php": ">=8.1", "symfony/stimulus-bundle": "^2.18.1", "symfony/ux-map": "^2.19" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/phpunit-bridge": "^7.2", "symfony/ux-icons": "^2.18", - "spatie/phpunit-snapshot-assertions": "^5.1.8" + "spatie/phpunit-snapshot-assertions": "^4.2.17", + "phpunit/phpunit": "^9.6.22" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\": "src/" }, diff --git a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist index 4049eb2ea15..751314ecb8e 100644 --- a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist +++ b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist @@ -12,11 +12,10 @@ ./src - + - diff --git a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php index 8dfc9cfde50..cc704d363bf 100644 --- a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php +++ b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php @@ -18,15 +18,15 @@ * * @author Hugo Alliaume */ -final readonly class TileLayer +final class TileLayer { /** * @param array $options */ public function __construct( - private string $url, - private string $attribution, - private array $options = [], + private readonly string $url, + private readonly string $attribution, + private readonly array $options = [], ) { } diff --git a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php index 05f1348ef72..652272f038e 100644 --- a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php @@ -20,7 +20,7 @@ * * @internal */ -final readonly class LeafletRenderer extends AbstractRenderer +final class LeafletRenderer extends AbstractRenderer { protected function getName(): string { diff --git a/src/Map/src/Distance/DistanceCalculator.php b/src/Map/src/Distance/DistanceCalculator.php index 46e7193d27a..43adfcabdd9 100644 --- a/src/Map/src/Distance/DistanceCalculator.php +++ b/src/Map/src/Distance/DistanceCalculator.php @@ -16,11 +16,11 @@ /** * @author Simon André */ -final readonly class DistanceCalculator implements DistanceCalculatorInterface +final class DistanceCalculator implements DistanceCalculatorInterface { public function __construct( - private DistanceCalculatorInterface $calculator = new VincentyDistanceCalculator(), - private DistanceUnit $unit = DistanceUnit::Meter, + private readonly DistanceCalculatorInterface $calculator = new VincentyDistanceCalculator(), + private readonly DistanceUnit $unit = DistanceUnit::Meter, ) { } diff --git a/src/Map/src/Distance/HaversineDistanceCalculator.php b/src/Map/src/Distance/HaversineDistanceCalculator.php index 32b18689b35..f4a9fe0c2fa 100644 --- a/src/Map/src/Distance/HaversineDistanceCalculator.php +++ b/src/Map/src/Distance/HaversineDistanceCalculator.php @@ -20,7 +20,7 @@ * * @author Simon André */ -final readonly class HaversineDistanceCalculator implements DistanceCalculatorInterface +final class HaversineDistanceCalculator implements DistanceCalculatorInterface { /** * @const float The Earth's radius in meters. diff --git a/src/Map/src/Distance/SphericalCosineDistanceCalculator.php b/src/Map/src/Distance/SphericalCosineDistanceCalculator.php index 7e5c1f63cf0..f77716a14b3 100644 --- a/src/Map/src/Distance/SphericalCosineDistanceCalculator.php +++ b/src/Map/src/Distance/SphericalCosineDistanceCalculator.php @@ -20,7 +20,7 @@ * * @author Simon André */ -final readonly class SphericalCosineDistanceCalculator implements DistanceCalculatorInterface +final class SphericalCosineDistanceCalculator implements DistanceCalculatorInterface { /** * @const float The Earth's radius in meters. diff --git a/src/Map/src/Distance/VincentyDistanceCalculator.php b/src/Map/src/Distance/VincentyDistanceCalculator.php index 7a08184a9db..db25a91ac05 100644 --- a/src/Map/src/Distance/VincentyDistanceCalculator.php +++ b/src/Map/src/Distance/VincentyDistanceCalculator.php @@ -20,7 +20,7 @@ * * @author Simon André */ -final readonly class VincentyDistanceCalculator implements DistanceCalculatorInterface +final class VincentyDistanceCalculator implements DistanceCalculatorInterface { /** * WS-84 ellipsoid parameters. diff --git a/src/Map/src/Icon/UxIconRenderer.php b/src/Map/src/Icon/UxIconRenderer.php index 1b99f4c6a67..1e9812014a3 100644 --- a/src/Map/src/Icon/UxIconRenderer.php +++ b/src/Map/src/Icon/UxIconRenderer.php @@ -18,10 +18,10 @@ * * @internal */ -readonly class UxIconRenderer +class UxIconRenderer { public function __construct( - private ?IconRendererInterface $renderer, + private readonly ?IconRendererInterface $renderer, ) { } diff --git a/src/Map/src/InfoWindow.php b/src/Map/src/InfoWindow.php index f3f1cb9dd5e..7f1136543e8 100644 --- a/src/Map/src/InfoWindow.php +++ b/src/Map/src/InfoWindow.php @@ -16,19 +16,19 @@ * * @author Hugo Alliaume */ -final readonly class InfoWindow +final class InfoWindow { /** * @param array $extra Extra data, can be used by the developer to store additional information and * use them later JavaScript side */ public function __construct( - private ?string $headerContent = null, - private ?string $content = null, - private ?Point $position = null, - private bool $opened = false, - private bool $autoClose = true, - private array $extra = [], + private readonly ?string $headerContent = null, + private readonly ?string $content = null, + private readonly ?Point $position = null, + private readonly bool $opened = false, + private readonly bool $autoClose = true, + private readonly array $extra = [], ) { } diff --git a/src/Map/src/MapOptionsNormalizer.php b/src/Map/src/MapOptionsNormalizer.php index 8233c00d71d..21866436d35 100644 --- a/src/Map/src/MapOptionsNormalizer.php +++ b/src/Map/src/MapOptionsNormalizer.php @@ -26,7 +26,10 @@ */ final class MapOptionsNormalizer { - private const string KEY_PROVIDER = '@provider'; + /** + * @var string + */ + private const KEY_PROVIDER = '@provider'; /** * @var array> diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php index 922bd06d706..ed58c4840c5 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -20,19 +20,19 @@ * * @author Hugo Alliaume */ -final readonly class Marker implements Element +final class Marker implements Element { /** * @param array $extra Extra data, can be used by the developer to store additional information and * use them later JavaScript side */ public function __construct( - public Point $position, - public ?string $title = null, - public ?InfoWindow $infoWindow = null, - public array $extra = [], - public ?string $id = null, - public ?Icon $icon = null, + public readonly Point $position, + public readonly ?string $title = null, + public readonly ?InfoWindow $infoWindow = null, + public readonly array $extra = [], + public readonly ?string $id = null, + public readonly ?Icon $icon = null, ) { } diff --git a/src/Map/src/Point.php b/src/Map/src/Point.php index 864041e2620..283f95ba615 100644 --- a/src/Map/src/Point.php +++ b/src/Map/src/Point.php @@ -18,11 +18,11 @@ * * @author Hugo Alliaume */ -final readonly class Point +final class Point { public function __construct( - public float $latitude, - public float $longitude, + public readonly float $latitude, + public readonly float $longitude, ) { if ($latitude < -90 || $latitude > 90) { throw new InvalidArgumentException(\sprintf('Latitude must be between -90 and 90 degrees, "%s" given.', $latitude)); diff --git a/src/Map/src/Polygon.php b/src/Map/src/Polygon.php index 4faaf7e86b9..1d526a1c285 100644 --- a/src/Map/src/Polygon.php +++ b/src/Map/src/Polygon.php @@ -18,17 +18,17 @@ * * @author [Pierre Svgnt] */ -final readonly class Polygon implements Element +final class Polygon implements Element { /** * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side */ public function __construct( - private array $points, - private ?string $title = null, - private ?InfoWindow $infoWindow = null, - private array $extra = [], - public ?string $id = null, + private readonly array $points, + private readonly ?string $title = null, + private readonly ?InfoWindow $infoWindow = null, + private readonly array $extra = [], + public readonly ?string $id = null, ) { } diff --git a/src/Map/src/Polyline.php b/src/Map/src/Polyline.php index 15b1b778f45..9a51e62ba9e 100644 --- a/src/Map/src/Polyline.php +++ b/src/Map/src/Polyline.php @@ -18,17 +18,17 @@ * * @author [Sylvain Blondeau] */ -final readonly class Polyline implements Element +final class Polyline implements Element { /** * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side */ public function __construct( - private array $points, - private ?string $title = null, - private ?InfoWindow $infoWindow = null, - private array $extra = [], - public ?string $id = null, + private readonly array $points, + private readonly ?string $title = null, + private readonly ?InfoWindow $infoWindow = null, + private readonly array $extra = [], + public readonly ?string $id = null, ) { } diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index 8b2ef9fd7cf..ad3ba4334b1 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -20,11 +20,11 @@ /** * @author Hugo Alliaume */ -abstract readonly class AbstractRenderer implements RendererInterface +abstract class AbstractRenderer implements RendererInterface { public function __construct( - private StimulusHelper $stimulus, - private UxIconRenderer $uxIconRenderer, + private readonly StimulusHelper $stimulus, + private readonly UxIconRenderer $uxIconRenderer, ) { } diff --git a/src/Map/src/Renderer/Dsn.php b/src/Map/src/Renderer/Dsn.php index ecac16ddff0..adde1abb8ef 100644 --- a/src/Map/src/Renderer/Dsn.php +++ b/src/Map/src/Renderer/Dsn.php @@ -16,13 +16,13 @@ /** * @author Hugo Alliaume */ -final readonly class Dsn +final class Dsn { - private string $scheme; - private string $host; - private ?string $user; - private array $options; - private string $originalDsn; + private readonly string $scheme; + private readonly string $host; + private readonly ?string $user; + private readonly array $options; + private readonly string $originalDsn; public function __construct(#[\SensitiveParameter] string $dsn) { diff --git a/src/Map/src/Renderer/NullRenderer.php b/src/Map/src/Renderer/NullRenderer.php index 76ab4a22612..772a3d7f2e3 100644 --- a/src/Map/src/Renderer/NullRenderer.php +++ b/src/Map/src/Renderer/NullRenderer.php @@ -19,10 +19,10 @@ * * @internal */ -final readonly class NullRenderer implements RendererInterface +final class NullRenderer implements RendererInterface { public function __construct( - private array $availableBridges = [], + private readonly array $availableBridges = [], ) { } diff --git a/src/Map/src/Renderer/NullRendererFactory.php b/src/Map/src/Renderer/NullRendererFactory.php index 0d2c28a7fb6..e27ccff7925 100644 --- a/src/Map/src/Renderer/NullRendererFactory.php +++ b/src/Map/src/Renderer/NullRendererFactory.php @@ -13,13 +13,13 @@ use Symfony\UX\Map\Exception\UnsupportedSchemeException; -final readonly class NullRendererFactory implements RendererFactoryInterface +final class NullRendererFactory implements RendererFactoryInterface { /** * @param array $availableBridges */ public function __construct( - private array $availableBridges = [], + private readonly array $availableBridges = [], ) { } diff --git a/src/Map/src/Renderer/Renderer.php b/src/Map/src/Renderer/Renderer.php index ca2da7fa071..6f230d66178 100644 --- a/src/Map/src/Renderer/Renderer.php +++ b/src/Map/src/Renderer/Renderer.php @@ -18,13 +18,13 @@ * * @internal */ -final readonly class Renderer +final class Renderer { public function __construct( /** * @param iterable $factories */ - private iterable $factories, + private readonly iterable $factories, ) { } diff --git a/src/Map/tests/DummyOptions.php b/src/Map/tests/DummyOptions.php index f04acc97a12..04c1f8fd8ce 100644 --- a/src/Map/tests/DummyOptions.php +++ b/src/Map/tests/DummyOptions.php @@ -16,11 +16,11 @@ use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\MapOptionsNormalizer; -final readonly class DummyOptions implements MapOptionsInterface +final class DummyOptions implements MapOptionsInterface { public function __construct( - private string $mapId, - private string $mapType, + private readonly string $mapId, + private readonly string $mapType, ) { } diff --git a/src/Map/tests/Twig/MapExtensionTest.php b/src/Map/tests/Twig/MapExtensionTest.php index e78e0ec3d16..c9850f1b795 100644 --- a/src/Map/tests/Twig/MapExtensionTest.php +++ b/src/Map/tests/Twig/MapExtensionTest.php @@ -80,8 +80,9 @@ public function testRenderFunctionIsDeprecated(): void if (class_exists(DeprecatedCallableInfo::class)) { $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated; use "ux_map" instead in test at line 1.'); } else { - $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated. Use "ux_map" instead in test at line 1.'); + $this->expectDeprecation('Twig Function "render_map" is deprecated since version 2.20. Use "ux_map" instead in test at line 1.'); } + $html = $twig->render('test', ['map' => $map]); $this->assertSame('', $html); } @@ -103,7 +104,7 @@ public function testMapFunctionWithArray(): void self::getContainer()->set('test.ux_map.renderers', $renderer); $twig = self::getContainer()->get('twig'); - $template = $twig->createTemplate('{{ ux_map(center: {lat: 5, lng: 10}, zoom: 4, attributes: attributes) }}'); + $template = $twig->createTemplate('{{ ux_map(center={lat: 5, lng: 10}, zoom=4, attributes=attributes) }}'); $this->assertSame( '
    ', From 44dbe5c1fdcbb03c80f6a680082a299e3cb6c446 Mon Sep 17 00:00:00 2001 From: Pierre Ambroise Date: Mon, 14 Apr 2025 18:58:46 +0200 Subject: [PATCH 19/58] Pass topics to mercure if not defined --- .../src/Bridge/Mercure/TurboStreamListenRenderer.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index a5e511ca9e7..1d4274107c8 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -65,11 +65,13 @@ public function renderTurboStreamListen(Environment $env, $topic /* array $event if (isset($eventSourceOptions)) { try { $mercure = $this->twig->getExtension(MercureExtension::class); - $mercure->mercure($topics, $eventSourceOptions); - if (isset($eventSourceOptions['withCredentials'])) { - $controllerAttributes['withCredentials'] = $eventSourceOptions['withCredentials']; + if ($eventSourceOptions['withCredentials'] ?? false) { + $eventSourceOptions['subscribe'] ??= $topics; + $controllerAttributes['withCredentials'] = true; } + + $mercure->mercure($topics, $eventSourceOptions); } catch (RuntimeError $e) { } } From 13dfc849d6942fda3f31cefe990677ec138526b3 Mon Sep 17 00:00:00 2001 From: stehled Date: Thu, 6 Mar 2025 08:54:26 +0100 Subject: [PATCH 20/58] [LiveComponent] `ComponentWithFormTrait` now correctly checks for a `TranslatableInterface` placeholder for `` elements - Add property name as second paramter to LiveProp modifier callback ## 2.23.0 diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index 00c98419263..fff01e0b73b 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -16,6 +16,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\PreReRender; use Symfony\UX\LiveComponent\Util\LiveFormUtility; @@ -286,7 +287,8 @@ private function extractFormValues(FormView $formView): array ) && !$child->vars['expanded'] // is a diff --git a/src/Toolkit/kits/shadcn/templates/components/Input.html.twig b/src/Toolkit/kits/shadcn/templates/components/Input.html.twig new file mode 100644 index 00000000000..5c0dd260ebc --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Input.html.twig @@ -0,0 +1,6 @@ +{%- props type = 'text' -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Label.html.twig b/src/Toolkit/kits/shadcn/templates/components/Label.html.twig new file mode 100644 index 00000000000..661d333d0e3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Label.html.twig @@ -0,0 +1,6 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Navbar.html.twig b/src/Toolkit/kits/shadcn/templates/components/Navbar.html.twig deleted file mode 100644 index 60a10b244d2..00000000000 --- a/src/Toolkit/kits/shadcn/templates/components/Navbar.html.twig +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig new file mode 100644 index 00000000000..3eaf0911b78 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig @@ -0,0 +1,7 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig new file mode 100644 index 00000000000..9034e2a9b72 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig @@ -0,0 +1,5 @@ +
      + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig new file mode 100644 index 00000000000..b79f11d0fac --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig @@ -0,0 +1,8 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig new file mode 100644 index 00000000000..1029344f0ea --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig @@ -0,0 +1,3 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig new file mode 100644 index 00000000000..86c8adc46fe --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig @@ -0,0 +1,10 @@ +{%- props isActive = false, size = 'icon' -%} + + {{- block(outerBlocks.content) -}} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig new file mode 100644 index 00000000000..cd09ce5b044 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig @@ -0,0 +1,9 @@ + + Next + + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig new file mode 100644 index 00000000000..1d09bd7739c --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig @@ -0,0 +1,9 @@ + + + Previous + diff --git a/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig b/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig new file mode 100644 index 00000000000..2d62f6c7e82 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig @@ -0,0 +1,12 @@ +{%- props value = 0 -%} + +
    +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Select.html.twig b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig new file mode 100644 index 00000000000..aad4affc10b --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig @@ -0,0 +1,6 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig new file mode 100644 index 00000000000..b85e39e0408 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig @@ -0,0 +1,18 @@ +{%- props orientation = 'horizontal', decorative = true -%} +{%- set style = html_cva( + base: 'shrink-0 bg-border', + variants: { + orientation: { + horizontal: 'h-[1px] w-full', + vertical: 'h-full w-[1px]', + }, + }, +) -%} +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig new file mode 100644 index 00000000000..0e1301616a7 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig @@ -0,0 +1,4 @@ +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig new file mode 100644 index 00000000000..dac0998fb52 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig @@ -0,0 +1,4 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig index f0bf2ab38b5..48c55aa2d93 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig @@ -1,13 +1,8 @@ -{%- props -%} -{%- set style = html_cva( - base: 'relative w-full overflow-auto', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} -
    +
    + + {%- block content %}{% endblock -%} +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig index 3ed0f02312b..f7efc6bc957 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: '[&_tr:last-child]:border-0', - variants: {}, - compoundVariants: [] -) -%} - - {% block content %}{% endblock %} + {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig index def36aac655..aa41d4be8ba 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: 'text-muted-foreground mt-4 text-sm', - variants: {}, - compoundVariants: [] -) -%} - - {% block content %}{% endblock %} + {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig index db584a4477e..9a01544a0e9 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: 'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - variants: {}, - compoundVariants: [] -) -%} - - {% block content %}{% endblock %} + {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig index cbd36d1e6e9..07503fbe3aa 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', - variants: {}, - compoundVariants: [] -) -%} - - {% block content %}{% endblock %} + {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig index 73e38418a6c..33273f96ea6 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: 'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - variants: {}, - compoundVariants: [] -) -%} - - {% block content %}{% endblock %} + {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig index e5ae84ab9dd..610756b8c59 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: '[&_tr]:border-b', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig index d3de2112b82..4e01c5c8850 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig @@ -1,13 +1,6 @@ -{%- props -%} -{%- set style = html_cva( - base: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', - variants: {}, - compoundVariants: [] -) -%} - - {% block content %}{% endblock %} + {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig new file mode 100644 index 00000000000..399593a0e8a --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig @@ -0,0 +1,4 @@ + From 752582359205f64789f383cdef2f6eb76f40a32f Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 31 Mar 2025 23:15:36 +0200 Subject: [PATCH 32/58] [Toolkit] Rework the documentation --- .github/workflows/toolkit-kits-cs.yaml | 28 +++ src/Toolkit/doc/index.rst | 14 +- .../kits/shadcn/docs/components/Input.md | 56 +++++ .../components/Avatar/Image.html.twig | 13 +- src/Toolkit/src/Kit/KitFactory.php | 1 - src/Toolkit/tests/Kit/KitFactoryTest.php | 2 +- ux.symfony.com/.platform.app.yaml | 2 + ux.symfony.com/.symfony.local.yaml | 2 + .../assets/controllers/tabs-controller.js | 12 +- .../assets/icons/simple-icons/shadcnui.svg | 1 + .../images/ux_packages/toolkit-1200x675.png | Bin 0 -> 179507 bytes .../assets/images/ux_packages/toolkit.png | Bin 0 -> 3609 bytes ux.symfony.com/assets/styles/app.scss | 7 +- .../styles/components/_CodePreview_Tabs.scss | 60 +++++ .../assets/styles/components/_SidebarNav.scss | 22 ++ .../assets/styles/components/_Wysiwyg.scss | 29 +++ .../assets/styles/toolkit-shadcn.css | 126 ++++++++++ .../assets/styles/utilities/_animation.scss | 8 + .../assets/styles/utilities/_shadow.scss | 5 + ux.symfony.com/assets/toolkit-shadcn.js | 1 + ux.symfony.com/composer.json | 4 +- ux.symfony.com/composer.lock | 58 ++++- ux.symfony.com/config/bundles.php | 3 +- .../packages/symfonycasts_tailwind.yaml | 9 + .../config/packages/twig_component.yaml | 2 +- .../config/packages/ux_toolkit.yaml | 6 +- ux.symfony.com/config/services.yaml | 3 + ux.symfony.com/importmap.php | 4 + .../src/Controller/SitemapController.php | 13 + .../Toolkit/ComponentsController.php | 150 ++++++++++++ .../src/Controller/Toolkit/KitsController.php | 46 ++++ .../UxPackage/ToolkitController.php | 107 +++----- ux.symfony.com/src/Enum/ToolkitKit.php | 162 +++++++++++++ ux.symfony.com/src/Model/UxPackage.php | 6 +- .../Service/CommonMark/ConverterFactory.php | 13 +- .../CodeBlockRenderer/CodeBlockRenderer.php | 99 ++++++++ .../src/Service/Toolkit/ToolkitService.php | 62 +++++ .../src/Service/ToolkitComponentService.php | 140 ----------- .../src/Service/UxPackageRepository.php | 4 +- .../src/Twig/Components/Code/CodeBlock.php | 4 +- .../Twig/Components/Toolkit/ComponentDoc.php | 102 ++++++++ ux.symfony.com/src/Util/SourceCleaner.php | 19 +- ux.symfony.com/symfony.lock | 15 ++ ux.symfony.com/templates/_header.html.twig | 11 +- .../templates/components/Alert.html.twig | 8 +- .../components/AspectRatio.html.twig | 9 - .../templates/components/Avatar.html.twig | 13 - .../Avatar/AvatarFallback.html.twig | 13 - .../components/Avatar/AvatarImage.html.twig | 11 - .../components/AvatarFallback.html.twig | 13 - .../components/AvatarImage.html.twig | 11 - .../templates/components/Badge.html.twig | 51 ++-- .../templates/components/Blank.html.twig | 13 - .../templates/components/Breadcrumb.html.twig | 13 - .../Breadcrumb/BreadcrumbEllipsis.html.twig | 20 -- .../Breadcrumb/BreadcrumbItem.html.twig | 13 - .../Breadcrumb/BreadcrumbLink.html.twig | 13 - .../Breadcrumb/BreadcrumbList.html.twig | 13 - .../Breadcrumb/BreadcrumbPage.html.twig | 18 -- .../Breadcrumb/BreadcrumbSeparator.html.twig | 20 -- .../components/BreadcrumbEllipsis.html.twig | 20 -- .../components/BreadcrumbItem.html.twig | 13 - .../components/BreadcrumbLink.html.twig | 13 - .../components/BreadcrumbList.html.twig | 13 - .../components/BreadcrumbPage.html.twig | 18 -- .../components/BreadcrumbSeparator.html.twig | 20 -- .../templates/components/Button.html.twig | 22 +- .../templates/components/Card.html.twig | 46 +++- .../components/Card/CardContent.html.twig | 13 - .../components/Card/CardDescription.html.twig | 13 - .../components/Card/CardFooter.html.twig | 13 - .../components/Card/CardHeader.html.twig | 13 - .../components/Card/CardTitle.html.twig | 13 - .../components/CardContent.html.twig | 13 - .../components/CardDescription.html.twig | 13 - .../templates/components/CardFooter.html.twig | 13 - .../templates/components/CardHeader.html.twig | 13 - .../templates/components/CardTitle.html.twig | 13 - .../components/Code/CodeBlockEmbed.html.twig | 4 +- .../templates/components/Grid.html.twig | 68 ------ .../templates/components/Grid/Col.html.twig | 77 ------ .../templates/components/Grid/Row.html.twig | 60 ----- .../templates/components/Navbar.html.twig | 5 - .../Package/PackageHeader.html.twig | 10 - .../components/Table/TableBody.html.twig | 13 - .../components/Table/TableCaption.html.twig | 13 - .../components/Table/TableCell.html.twig | 13 - .../components/Table/TableFooter.html.twig | 13 - .../components/Table/TableHead.html.twig | 13 - .../components/Table/TableHeader.html.twig | 13 - .../components/Table/TableRow.html.twig | 13 - .../components/Toolkit/ComponentDoc.html.twig | 3 + .../toolkit/_code_block_right.html.twig | 17 ++ .../templates/toolkit/_kit_aside.html.twig | 23 ++ .../templates/toolkit/component.html.twig | 39 +++ .../templates/toolkit/kit.html.twig | 40 +++ .../ux_packages/_package_install.html.twig | 2 +- .../templates/ux_packages/toolkit.html.twig | 228 ++++++------------ .../ux_packages/toolkit/components.html.twig | 107 -------- .../ux_packages/toolkit/preview.html.twig | 5 - .../templates/ux_toolkit_preview.html.twig | 91 ------- 101 files changed, 1428 insertions(+), 1420 deletions(-) create mode 100644 .github/workflows/toolkit-kits-cs.yaml create mode 100644 src/Toolkit/kits/shadcn/docs/components/Input.md create mode 100644 ux.symfony.com/assets/icons/simple-icons/shadcnui.svg create mode 100644 ux.symfony.com/assets/images/ux_packages/toolkit-1200x675.png create mode 100644 ux.symfony.com/assets/images/ux_packages/toolkit.png create mode 100644 ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss create mode 100644 ux.symfony.com/assets/styles/components/_SidebarNav.scss create mode 100644 ux.symfony.com/assets/styles/components/_Wysiwyg.scss create mode 100644 ux.symfony.com/assets/styles/toolkit-shadcn.css create mode 100644 ux.symfony.com/assets/styles/utilities/_animation.scss create mode 100644 ux.symfony.com/assets/toolkit-shadcn.js create mode 100644 ux.symfony.com/config/packages/symfonycasts_tailwind.yaml create mode 100644 ux.symfony.com/src/Controller/Toolkit/ComponentsController.php create mode 100644 ux.symfony.com/src/Controller/Toolkit/KitsController.php create mode 100644 ux.symfony.com/src/Enum/ToolkitKit.php create mode 100644 ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php create mode 100644 ux.symfony.com/src/Service/Toolkit/ToolkitService.php delete mode 100644 ux.symfony.com/src/Service/ToolkitComponentService.php create mode 100644 ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php delete mode 100644 ux.symfony.com/templates/components/AspectRatio.html.twig delete mode 100644 ux.symfony.com/templates/components/Avatar.html.twig delete mode 100644 ux.symfony.com/templates/components/Avatar/AvatarFallback.html.twig delete mode 100644 ux.symfony.com/templates/components/Avatar/AvatarImage.html.twig delete mode 100644 ux.symfony.com/templates/components/AvatarFallback.html.twig delete mode 100644 ux.symfony.com/templates/components/AvatarImage.html.twig delete mode 100644 ux.symfony.com/templates/components/Blank.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb/BreadcrumbEllipsis.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb/BreadcrumbItem.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb/BreadcrumbLink.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb/BreadcrumbList.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb/BreadcrumbPage.html.twig delete mode 100644 ux.symfony.com/templates/components/Breadcrumb/BreadcrumbSeparator.html.twig delete mode 100644 ux.symfony.com/templates/components/BreadcrumbEllipsis.html.twig delete mode 100644 ux.symfony.com/templates/components/BreadcrumbItem.html.twig delete mode 100644 ux.symfony.com/templates/components/BreadcrumbLink.html.twig delete mode 100644 ux.symfony.com/templates/components/BreadcrumbList.html.twig delete mode 100644 ux.symfony.com/templates/components/BreadcrumbPage.html.twig delete mode 100644 ux.symfony.com/templates/components/BreadcrumbSeparator.html.twig delete mode 100644 ux.symfony.com/templates/components/Card/CardContent.html.twig delete mode 100644 ux.symfony.com/templates/components/Card/CardDescription.html.twig delete mode 100644 ux.symfony.com/templates/components/Card/CardFooter.html.twig delete mode 100644 ux.symfony.com/templates/components/Card/CardHeader.html.twig delete mode 100644 ux.symfony.com/templates/components/Card/CardTitle.html.twig delete mode 100644 ux.symfony.com/templates/components/CardContent.html.twig delete mode 100644 ux.symfony.com/templates/components/CardDescription.html.twig delete mode 100644 ux.symfony.com/templates/components/CardFooter.html.twig delete mode 100644 ux.symfony.com/templates/components/CardHeader.html.twig delete mode 100644 ux.symfony.com/templates/components/CardTitle.html.twig delete mode 100644 ux.symfony.com/templates/components/Grid.html.twig delete mode 100644 ux.symfony.com/templates/components/Grid/Col.html.twig delete mode 100644 ux.symfony.com/templates/components/Grid/Row.html.twig delete mode 100644 ux.symfony.com/templates/components/Navbar.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableBody.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableCaption.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableCell.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableFooter.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableHead.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableHeader.html.twig delete mode 100644 ux.symfony.com/templates/components/Table/TableRow.html.twig create mode 100644 ux.symfony.com/templates/components/Toolkit/ComponentDoc.html.twig create mode 100644 ux.symfony.com/templates/toolkit/_code_block_right.html.twig create mode 100644 ux.symfony.com/templates/toolkit/_kit_aside.html.twig create mode 100644 ux.symfony.com/templates/toolkit/component.html.twig create mode 100644 ux.symfony.com/templates/toolkit/kit.html.twig delete mode 100644 ux.symfony.com/templates/ux_packages/toolkit/components.html.twig delete mode 100644 ux.symfony.com/templates/ux_packages/toolkit/preview.html.twig delete mode 100644 ux.symfony.com/templates/ux_toolkit_preview.html.twig diff --git a/.github/workflows/toolkit-kits-cs.yaml b/.github/workflows/toolkit-kits-cs.yaml new file mode 100644 index 00000000000..fcd5e36f3c2 --- /dev/null +++ b/.github/workflows/toolkit-kits-cs.yaml @@ -0,0 +1,28 @@ +name: Toolkit Kits + +on: + push: + paths: + - 'src/Toolkit/kits/**' + pull_request: + paths: + - 'src/Toolkit/kits/**' + +jobs: + kits-cs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Install composer packages + uses: ramsey/composer-install@v3 + with: + working-directory: src/Toolkit + + - name: Check kits code style + run: php vendor/bin/twig-cs-fixer check kits + working-directory: src/Toolkit diff --git a/src/Toolkit/doc/index.rst b/src/Toolkit/doc/index.rst index eb9d3e56124..b2d0aad554d 100644 --- a/src/Toolkit/doc/index.rst +++ b/src/Toolkit/doc/index.rst @@ -7,7 +7,7 @@ to change, or even change drastically. Symfony UX Toolkit provides a set of ready-to-use kits for Symfony applications. It is part of `the Symfony UX initiative`_. -Kits are a nice way to begin a new Symfony application, by providing a set +Kits are a nice way to begin a new Symfony application, by providing a set of `Twig components`_ (based on Tailwind CSS, but fully customizable depending on your needs). @@ -24,8 +24,8 @@ Additionally, some `Twig components`_ use ``html_cva`` and ``tailwind_merge``, you can either remove them from your project or install ``twig/html-extra`` and ``tales-from-a-dev/twig-tailwind-extra`` to use them. -Also, we do not force you to use Tailwind CSS at all. If you want to use -another CSS framework, you can, but you will need to adapt the UI components to it. +Also, we do not force you to use Tailwind CSS at all. You can use whatever +CSS framework you want, but you will need to adapt the UI components to it. Installation ------------ @@ -37,7 +37,7 @@ Install the UX Toolkit using Composer and Symfony Flex: # The UX Toolkit is a development dependency: $ composer require --dev symfony/ux-toolkit - # If you want to keep `html_cva` and `tailwind_merge` in your UI components: + # If you want to keep `html_cva` and `tailwind_merge` in your Twig components: $ composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra Configuration @@ -71,7 +71,7 @@ It will create the ``templates/components/Button.html.twig`` file, and you will Create your own kit ------------------- -You have the ability to create and share your own kit with the community, +You have the ability to create and share your own kit with the community, by using the ``php vendor/bin/ux-toolkit-kit-create`` command in a new GitHub repository: .. code-block:: terminal @@ -89,7 +89,7 @@ by using the ``php vendor/bin/ux-toolkit-kit-create`` command in a new GitHub re # Create your kit $ php vendor/bin/ux-toolkit-kit-create - + # ... edit the files, add your components, examples, etc. # Share your kit @@ -124,7 +124,7 @@ A kit is composed of: Use your kit in a Symfony application ------------------------------------- -You can globally configure the kit to use in your application by setting the ``ux_toolkit.kit`` parameter: +You can globally configure the kit to use in your application by setting the ``ux_toolkit.kit`` configuration: .. code-block:: yaml diff --git a/src/Toolkit/kits/shadcn/docs/components/Input.md b/src/Toolkit/kits/shadcn/docs/components/Input.md new file mode 100644 index 00000000000..8a01c3c4ead --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Input.md @@ -0,0 +1,56 @@ +# Input + +A form input component for text, email, password, and other input types. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### File + +```twig {"preview":true} +
    + + +
    +``` + +### Disabled + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
    + Email + +
    +``` + +### With Button + +```twig {"preview":true} +
    + + Subscribe +
    +``` diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig index 7d1f9fc99f2..2863553f8df 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig @@ -1,11 +1,4 @@ -{%- props -%} -{%- set style = html_cva( - base: 'aspect-square h-full w-full', - variants: {}, - compoundVariants: [] -) -%} - \ No newline at end of file + class="{{ 'aspect-square h-full w-full ' ~ attributes.render('class')|tailwind_merge }}" + {{ attributes.defaults({alt: ''}).without('class') }} +/> diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php index 6744d3f6465..9e77d18e399 100644 --- a/src/Toolkit/src/Kit/KitFactory.php +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -14,7 +14,6 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; -use Symfony\Component\Yaml\Yaml; use Symfony\UX\Toolkit\Component\Component; use Symfony\UX\Toolkit\Dependency\DependenciesResolver; use Symfony\UX\Toolkit\File\Doc; diff --git a/src/Toolkit/tests/Kit/KitFactoryTest.php b/src/Toolkit/tests/Kit/KitFactoryTest.php index 3ec4ad5aadd..5efe7757a0f 100644 --- a/src/Toolkit/tests/Kit/KitFactoryTest.php +++ b/src/Toolkit/tests/Kit/KitFactoryTest.php @@ -68,7 +68,7 @@ public function testCanCreateKit(): void A component for displaying structured data in rows and columns with support for headers, captions, and customizable styling. EOF - , $table->doc->markdownContent); + , $table->doc->markdownContent); } private function createKitFactory(): KitFactory diff --git a/ux.symfony.com/.platform.app.yaml b/ux.symfony.com/.platform.app.yaml index 5681b6a4d8f..6faf6c8b062 100644 --- a/ux.symfony.com/.platform.app.yaml +++ b/ux.symfony.com/.platform.app.yaml @@ -53,6 +53,8 @@ hooks: export NO_NPM=1 (>&2 symfony-build) + php bin/console tailwind:build --minify + php bin/console asset-map:compile deploy: | set -x -e diff --git a/ux.symfony.com/.symfony.local.yaml b/ux.symfony.com/.symfony.local.yaml index 4257e69ba26..77319f515f3 100644 --- a/ux.symfony.com/.symfony.local.yaml +++ b/ux.symfony.com/.symfony.local.yaml @@ -2,6 +2,8 @@ workers: docker_compose: ~ sass: cmd: ['symfony', 'console', 'sass:build', '--watch'] + tailwind: + cmd: ['symfony', 'console', 'tailwind:build', '--watch'] http: use_gzip: true diff --git a/ux.symfony.com/assets/controllers/tabs-controller.js b/ux.symfony.com/assets/controllers/tabs-controller.js index 216cd8f16a5..6def07b5493 100644 --- a/ux.symfony.com/assets/controllers/tabs-controller.js +++ b/ux.symfony.com/assets/controllers/tabs-controller.js @@ -1,16 +1,16 @@ -import {Controller} from '@hotwired/stimulus'; -import {getComponent} from '@symfony/ux-live-component'; +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; export default class extends Controller { static targets = ["tab", "control"] - static values = {tab: String} - static classes = [ "active" ] + static values = { tab: String } + static classes = ["active"] initialize() { this.showTab(this.tabValue); } - show({ params: { tab }}) { + show({ params: { tab } }) { this.tabValue = tab; } @@ -20,6 +20,7 @@ export default class extends Controller { const controlTarget = this.getControlTarget(tab); controlTarget.classList.add(this.activeClass); + controlTarget.setAttribute("aria-selected", "true"); } hideTab(tab) { @@ -28,6 +29,7 @@ export default class extends Controller { const controlTarget = this.getControlTarget(tab); controlTarget.classList.remove(this.activeClass); + controlTarget.setAttribute("aria-selected", "false"); } tabValueChanged(value, previousValue) { diff --git a/ux.symfony.com/assets/icons/simple-icons/shadcnui.svg b/ux.symfony.com/assets/icons/simple-icons/shadcnui.svg new file mode 100644 index 00000000000..467a4e74148 --- /dev/null +++ b/ux.symfony.com/assets/icons/simple-icons/shadcnui.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux.symfony.com/assets/images/ux_packages/toolkit-1200x675.png b/ux.symfony.com/assets/images/ux_packages/toolkit-1200x675.png new file mode 100644 index 0000000000000000000000000000000000000000..eaaccaf8d622aef7cc685b483c5740fb357ed9ff GIT binary patch literal 179507 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%0Dndsx|MGu^W@MXe2kz5%&{>2xa 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

    d<~lvucK8gW)SZ0FCur=#t) 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

    !c0pBT?ihsz5JBAXB+0v4&&tfciQoMZE% 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 E52YYBN&o-= literal 0 HcmV?d00001 diff --git a/ux.symfony.com/assets/styles/app.scss b/ux.symfony.com/assets/styles/app.scss index 27873192b1b..dcedeeed626 100644 --- a/ux.symfony.com/assets/styles/app.scss +++ b/ux.symfony.com/assets/styles/app.scss @@ -75,7 +75,7 @@ $utilities: map-remove( // @import "../../vendor/twbs/bootstrap/scss/navbar"; @import "../../vendor/twbs/bootstrap/scss/card"; // @import "../../vendor/twbs/bootstrap/scss/accordion"; -// @import "../../vendor/twbs/bootstrap/scss/breadcrumb"; +@import "../../vendor/twbs/bootstrap/scss/breadcrumb"; // @import "../../vendor/twbs/bootstrap/scss/pagination"; // @import "../../vendor/twbs/bootstrap/scss/badge"; @import "../../vendor/twbs/bootstrap/scss/alert"; @@ -128,6 +128,7 @@ $utilities: map-remove( @import "components/Button"; @import "components/Browser"; @import "components/Changelog"; +@import "components/CodePreview_Tabs"; @import "components/DataList"; @import "components/DemoContainer"; @import "components/DemoCard"; @@ -144,6 +145,7 @@ $utilities: map-remove( @import "components/PackageHeader"; @import "components/PackageBox"; @import "components/PackageList"; +@import "components/SidebarNav"; @import "components/Cookbook"; @import "components/SupportBox"; @import "components/Tabs"; @@ -151,8 +153,10 @@ $utilities: map-remove( @import "components/Terminal"; @import "components/TerminalCommand"; @import "components/ThemeSwitcher"; +@import "components/Wysiwyg"; // Utilities +@import "utilities/animation"; @import "utilities/arrow"; @import "utilities/background"; @import "utilities/info-tooltips"; @@ -176,4 +180,3 @@ $utilities: map-remove( .code-description a { text-decoration: underline; } - diff --git a/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss b/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss new file mode 100644 index 00000000000..0398deaffa5 --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss @@ -0,0 +1,60 @@ +.CodePreview_Tabs { +} + +.CodePreview_TabHead { + display: flex; + flex-direction: row; + margin-bottom: 1rem +} + +.CodePreview_TabControl { + border-bottom: 3px solid transparent; + color: var(--bs-primary-color); + padding: 0 1rem; + font-size: .9rem; + line-height: 2; + font-stretch: semi-condensed; + transition: border-color 150ms ease-in-out; + margin-bottom: -1px; +} + +.CodePreview_TabControl.active { + border-color: var(--bs-secondary-color); +} + +.CodePreview_TabPanel { + position: relative; +} + +.CodePreview_TabPanel:not(.active) { + display: none; +} + +.CodePreview_TabPanel:has(.CodePreview_Preview) { + border: 1px solid var(--bs-border-color); + border-radius: .75rem +} + +.CodePreview_Loader { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + gap: .2rem; + position: absolute; + + svg { + animation: rotating 1s linear infinite; + } +} + +.CodePreview_Preview { + width: 100%; + transition: opacity .250s linear; + border-radius: .75rem; + opacity: 1; + + &.loading { + opacity: 0; + } +} diff --git a/ux.symfony.com/assets/styles/components/_SidebarNav.scss b/ux.symfony.com/assets/styles/components/_SidebarNav.scss new file mode 100644 index 00000000000..7d890eea988 --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_SidebarNav.scss @@ -0,0 +1,22 @@ +.SidebarNav {} + +.SidebarNav_Heading { + font-weight: 600; + padding: 0 .5rem; +} + +.SidebarNav_Item { + border-radius: .5rem; + transition: background-color 100ms ease-in-out; +} +.SidebarNav_Item.active, +.SidebarNav_Item:hover { + background-color: var(--bs-secondary-bg); +} + + +.SidebarNav_Link { + display: block; + font-size: .9rem; + padding: .1rem .5rem; +} diff --git a/ux.symfony.com/assets/styles/components/_Wysiwyg.scss b/ux.symfony.com/assets/styles/components/_Wysiwyg.scss new file mode 100644 index 00000000000..739279b010d --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_Wysiwyg.scss @@ -0,0 +1,29 @@ +.Wysiwyg { + h1 { + font-family: var(--font-family-title); + font-size: 2.6rem; + font-weight: 700; + margin-bottom: 1rem; + } + + h2 { + font-family: var(--font-family-title); + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; + margin-top: 2rem; + } + + h3 { + font-family: var(--font-family-title); + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + margin-top: 1.5rem; + } + + a { + color: var(--bs-link-color); + text-decoration: underline; + } +} diff --git a/ux.symfony.com/assets/styles/toolkit-shadcn.css b/ux.symfony.com/assets/styles/toolkit-shadcn.css new file mode 100644 index 00000000000..a012610b7f3 --- /dev/null +++ b/ux.symfony.com/assets/styles/toolkit-shadcn.css @@ -0,0 +1,126 @@ +@import "tailwindcss"; +@source "../../vendor/symfony/ux-toolkit/kits"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + border-color: var(--border); + outline-color: var(--ring); + } + + body { + background-color: var(--background); + color: var(--foreground); + } +} diff --git a/ux.symfony.com/assets/styles/utilities/_animation.scss b/ux.symfony.com/assets/styles/utilities/_animation.scss new file mode 100644 index 00000000000..126431ca1c6 --- /dev/null +++ b/ux.symfony.com/assets/styles/utilities/_animation.scss @@ -0,0 +1,8 @@ +@keyframes rotating { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/ux.symfony.com/assets/styles/utilities/_shadow.scss b/ux.symfony.com/assets/styles/utilities/_shadow.scss index 775f59d3d7a..692fc38bf94 100644 --- a/ux.symfony.com/assets/styles/utilities/_shadow.scss +++ b/ux.symfony.com/assets/styles/utilities/_shadow.scss @@ -9,8 +9,13 @@ border-radius: 3rem; bottom: var(--shadow-bottom, 0); filter: blur(3rem); + opacity: var(--opacity, 1); } } .shadow-blur--rainbow { --gradient: linear-gradient(113.84deg, #D65831 0%, #D2D631 36.52%, #31D673 71.83%, #3aa3ff 100%) } + +.shadow-blur--opacity-20 { + --opacity: 0.2; +} diff --git a/ux.symfony.com/assets/toolkit-shadcn.js b/ux.symfony.com/assets/toolkit-shadcn.js new file mode 100644 index 00000000000..263378a5c3b --- /dev/null +++ b/ux.symfony.com/assets/toolkit-shadcn.js @@ -0,0 +1 @@ +import './styles/toolkit-shadcn.css'; diff --git a/ux.symfony.com/composer.json b/ux.symfony.com/composer.json index 978ff2b4213..d9be8be8509 100644 --- a/ux.symfony.com/composer.json +++ b/ux.symfony.com/composer.json @@ -56,6 +56,7 @@ "symfony/yaml": "7.2.*", "symfonycasts/dynamic-forms": "^0.1.2", "symfonycasts/sass-bundle": "0.8.*", + "symfonycasts/tailwind-bundle": "^0.9.0", "tales-from-a-dev/twig-tailwind-extra": "^0.3.0", "tempest/highlight": "^2.11.2", "twbs/bootstrap": "^5.3.3", @@ -135,7 +136,8 @@ "auto-scripts": { "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd", - "importmap:install": "symfony-cmd" + "importmap:install": "symfony-cmd", + "tailwind:build": "symfony-cmd" } } } diff --git a/ux.symfony.com/composer.lock b/ux.symfony.com/composer.lock index 219a71431b4..70da9028284 100644 --- a/ux.symfony.com/composer.lock +++ b/ux.symfony.com/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b8787db30eccf7944de0d8e856d7c877", + "content-hash": "4c63747018e3269a9e8d6adcd246355b", "packages": [ { "name": "composer/semver", @@ -9399,6 +9399,62 @@ }, "time": "2024-10-22T16:58:17+00:00" }, + { + "name": "symfonycasts/tailwind-bundle", + "version": "v0.9.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/tailwind-bundle.git", + "reference": "408c65b9ed0a7a25e17f9f86d5d5d60bbbfcc464" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/tailwind-bundle/zipball/408c65b9ed0a7a25e17f9f86d5d5d60bbbfcc464", + "reference": "408c65b9ed0a7a25e17f9f86d5d5d60bbbfcc464", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/asset-mapper": "^6.3|^7.0", + "symfony/cache": "^6.3|^7.0", + "symfony/console": "^5.4|^6.3|^7.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/http-client": "^5.4|^6.3|^7.0", + "symfony/process": "^5.4|^6.3|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "symfony/filesystem": "^6.3|^7.0", + "symfony/framework-bundle": "^6.3|^7.0", + "symfony/phpunit-bridge": "^6.3.9|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfonycasts\\TailwindBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "homepage": "https://symfonycasts.com" + } + ], + "description": "Delightful Tailwind Support for Symfony + AssetMapper", + "keywords": [ + "asset-mapper", + "tailwind" + ], + "support": { + "issues": "https://github.com/SymfonyCasts/tailwind-bundle/issues", + "source": "https://github.com/SymfonyCasts/tailwind-bundle/tree/v0.9.0" + }, + "time": "2025-03-22T13:36:15+00:00" + }, { "name": "tales-from-a-dev/twig-tailwind-extra", "version": "v0.3.0", diff --git a/ux.symfony.com/config/bundles.php b/ux.symfony.com/config/bundles.php index 57ddbefff51..ac7e54aa723 100644 --- a/ux.symfony.com/config/bundles.php +++ b/ux.symfony.com/config/bundles.php @@ -32,6 +32,7 @@ Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Symfony\UX\Map\UXMapBundle::class => ['all' => true], - Symfony\UX\Toolkit\UXToolkitBundle::class => ['dev' => true], + Symfony\UX\Toolkit\UXToolkitBundle::class => ['all' => true], TalesFromADev\Twig\Extra\Tailwind\Bridge\Symfony\Bundle\TalesFromADevTwigExtraTailwindBundle::class => ['all' => true], + Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['all' => true], ]; diff --git a/ux.symfony.com/config/packages/symfonycasts_tailwind.yaml b/ux.symfony.com/config/packages/symfonycasts_tailwind.yaml new file mode 100644 index 00000000000..50eb5f3a5d7 --- /dev/null +++ b/ux.symfony.com/config/packages/symfonycasts_tailwind.yaml @@ -0,0 +1,9 @@ +symfonycasts_tailwind: + # Specify the EXACT version of Tailwind CSS you want to use + binary_version: 'v4.1.1' + + # Alternatively, you can specify the path to the binary that you manage yourself + #binary: 'node_modules/.bin/tailwindcss' + + input_css: + - assets/styles/toolkit-shadcn.css diff --git a/ux.symfony.com/config/packages/twig_component.yaml b/ux.symfony.com/config/packages/twig_component.yaml index 388d533e4ec..da15821d627 100644 --- a/ux.symfony.com/config/packages/twig_component.yaml +++ b/ux.symfony.com/config/packages/twig_component.yaml @@ -1,7 +1,7 @@ twig_component: defaults: App\Twig\Components\: 'components/' - + # Custom namespace for MemoryDemo App\LiveMemory\Component\: template_directory: 'demos/live_memory/components/LiveMemory/' diff --git a/ux.symfony.com/config/packages/ux_toolkit.yaml b/ux.symfony.com/config/packages/ux_toolkit.yaml index 163295af88d..92e23b6fd30 100644 --- a/ux.symfony.com/config/packages/ux_toolkit.yaml +++ b/ux.symfony.com/config/packages/ux_toolkit.yaml @@ -1,4 +1,2 @@ -when@dev: - ux_toolkit: - theme: default - prefix: null \ No newline at end of file +ux_toolkit: + kit: shadcn diff --git a/ux.symfony.com/config/services.yaml b/ux.symfony.com/config/services.yaml index 95f38b16b4c..38d4b5dd9a7 100644 --- a/ux.symfony.com/config/services.yaml +++ b/ux.symfony.com/config/services.yaml @@ -24,3 +24,6 @@ services: # please note that last definitions always *replace* previous ones Tempest\Highlight\Highlighter: tags: ['twig.runtime'] + + ux_toolkit.registry.factory: + alias: '.ux_toolkit.registry.factory' diff --git a/ux.symfony.com/importmap.php b/ux.symfony.com/importmap.php index a09ee73a588..3b6259f2d12 100644 --- a/ux.symfony.com/importmap.php +++ b/ux.symfony.com/importmap.php @@ -32,6 +32,10 @@ 'path' => './assets/demos/live-memory.js', 'entrypoint' => true, ], + 'toolkit-shadcn' => [ + 'path' => './assets/toolkit-shadcn.js', + 'entrypoint' => true, + ], '@symfony/stimulus-bundle' => [ 'path' => '@symfony/stimulus-bundle/loader.js', ], diff --git a/ux.symfony.com/src/Controller/SitemapController.php b/ux.symfony.com/src/Controller/SitemapController.php index e139565d866..c9c1371a0cf 100644 --- a/ux.symfony.com/src/Controller/SitemapController.php +++ b/ux.symfony.com/src/Controller/SitemapController.php @@ -11,7 +11,9 @@ namespace App\Controller; +use App\Enum\ToolkitKit; use App\Service\LiveDemoRepository; +use App\Service\Toolkit\ToolkitService; use App\Service\UxPackageRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -24,6 +26,7 @@ final class SitemapController extends AbstractController public function __construct( private readonly UxPackageRepository $uxPackageRepository, private readonly LiveDemoRepository $liveDemoRepository, + private readonly ToolkitService $toolkitService, ) { } @@ -62,6 +65,16 @@ private function getSitemapUrls(): iterable foreach ($this->liveDemoRepository->findAll() as $demo) { yield $this->generateAbsoluteUrl($demo->getRoute()); } + + // Toolkit kits + foreach ($this->toolkitService->getKits() as $kitName => $kit) { + yield $this->generateAbsoluteUrl('app_toolkit_kit', ['kit' => $kitName]); + + $toolkitKit = ToolkitKit::from($kitName); + foreach ($this->toolkitService->getDocumentableComponents($toolkitKit) as $component) { + yield $this->generateAbsoluteUrl('app_toolkit_component', ['kit' => $kitName, 'componentName' => $component->name]); + } + } } private function generateAbsoluteUrl(string $route, array $parameters = []): string diff --git a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php new file mode 100644 index 00000000000..6445459d415 --- /dev/null +++ b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller\Toolkit; + +use App\Enum\ToolkitKit; +use App\Service\Toolkit\ToolkitService; +use App\Service\UxPackageRepository; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Profiler\Profiler; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\UX\Toolkit\File\FileType; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; +use Twig\Loader\ChainLoader; +use Twig\Loader\FilesystemLoader; + +class ComponentsController extends AbstractController +{ + public function __construct( + private ToolkitService $toolkitService, + private UxPackageRepository $uxPackageRepository, + ) { + } + + #[Route('/toolkit/kits/{kit}/components/')] + public function listComponents(ToolkitKit $kit): Response + { + // TODO: implementing listing in the future :D + + return $this->redirectToRoute('app_toolkit_kit', [ + 'kit' => $kit->value, + ], Response::HTTP_FOUND); + } + + #[Route('/toolkit/kits/{kit}/components/{componentName}', name: 'app_toolkit_component')] + public function showComponent(ToolkitKit $kit, string $componentName): Response + { + if (null === $component = $this->toolkitService->getComponent($kit, $componentName)) { + throw $this->createNotFoundException(\sprintf('Component "%s" not found', $componentName)); + } + + $package = $this->uxPackageRepository->find('toolkit'); + + return $this->render('toolkit/component.html.twig', [ + 'package' => $package, + 'kit' => $kit, + 'components' => $this->toolkitService->getDocumentableComponents($kit), + 'component' => $component, + ]); + } + + #[Route('/toolkit/component_preview', name: 'app_toolkit_component_preview')] + public function previewComponent( + Request $request, + #[MapQueryParameter] ToolkitKit $toolkitKit, + #[MapQueryParameter] string $code, + #[MapQueryParameter] string $height, + UriSigner $uriSigner, + \Twig\Environment $twig, + #[Autowire(service: 'ux.twig_component.component_factory')] + ComponentFactory $componentFactory, + #[Autowire(service: 'profiler')] + ?Profiler $profiler, + ): Response { + if (!$uriSigner->checkRequest($request)) { + throw new BadRequestHttpException('Request is invalid.'); + } + + $profiler?->disable(); + + $kit = $this->toolkitService->getKit($toolkitKit); + + $twig->setLoader(new ChainLoader([ + new FilesystemLoader($kit->path.\DIRECTORY_SEPARATOR.'templates'.\DIRECTORY_SEPARATOR.'components'), + $twig->getLoader(), + ])); + + $this->tweakComponentFactory( + $componentFactory, + new class($kit) implements ComponentTemplateFinderInterface { + public function __construct( + private readonly Kit $kit, + ) { + } + + public function findAnonymousComponentTemplate(string $name): ?string + { + if ($component = $this->kit->getComponent($name)) { + foreach ($component->files as $file) { + if (FileType::Twig === $file->type) { + return $file->relativePathName; + } + } + } + + return null; + } + } + ); + + $template = $twig->createTemplate(<< + + + Preview + + {{ importmap('toolkit-{$toolkitKit->value}') }} + + {$code} + +HTML); + + return new Response( + $twig->render($template), + Response::HTTP_OK, + ['X-Robots-Tag' => 'noindex, nofollow'] + ); + } + + /** + * Tweak the ComponentFactory to render anonymous components from the Toolkit kit. + * TODO: In the future, we should implement multiple directories for anonymous components. + */ + private function tweakComponentFactory(ComponentFactory $componentFactory, ComponentTemplateFinderInterface $componentTemplateFinder): void + { + $refl = new \ReflectionClass($componentFactory); + + $propertyConfig = $refl->getProperty('config'); + $propertyConfig->setValue($componentFactory, []); + + $propertyComponentTemplateFinder = $refl->getProperty('componentTemplateFinder'); + $propertyComponentTemplateFinder->setValue($componentFactory, $componentTemplateFinder); + } +} diff --git a/ux.symfony.com/src/Controller/Toolkit/KitsController.php b/ux.symfony.com/src/Controller/Toolkit/KitsController.php new file mode 100644 index 00000000000..78d26179e2a --- /dev/null +++ b/ux.symfony.com/src/Controller/Toolkit/KitsController.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller\Toolkit; + +use App\Enum\ToolkitKit; +use App\Service\Toolkit\ToolkitService; +use App\Service\UxPackageRepository; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +class KitsController extends AbstractController +{ + public function __construct( + private ToolkitService $toolkitService, + private UxPackageRepository $uxPackageRepository, + ) { + } + + #[Route('/toolkit/kits')] + public function listKits(): Response + { + return $this->redirectToRoute('app_toolkit', ['_fragment' => 'kits']); + } + + #[Route('/toolkit/kits/{kit}', name: 'app_toolkit_kit')] + public function showKit(ToolkitKit $kit): Response + { + $package = $this->uxPackageRepository->find('toolkit'); + + return $this->render('toolkit/kit.html.twig', [ + 'package' => $package, + 'kit' => $kit, + 'components' => $this->toolkitService->getDocumentableComponents($kit), + ]); + } +} diff --git a/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php b/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php index 4f97c3dc63a..8a480999f49 100644 --- a/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php +++ b/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php @@ -11,94 +11,49 @@ namespace App\Controller\UxPackage; -use App\Service\ToolkitComponentService; +use App\Enum\ToolkitKit; use App\Service\UxPackageRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Attribute\Route; -use Symfony\UX\Toolkit\Registry\Registry; -use Symfony\UX\Toolkit\Registry\RegistryItemType; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class ToolkitController extends AbstractController { #[Route('/toolkit', name: 'app_toolkit')] - public function index(UxPackageRepository $packageRepository, Registry $registry): Response + public function index(UxPackageRepository $packageRepository, UriSigner $uriSigner): Response { $package = $packageRepository->find('toolkit'); - return $this->render('ux_packages/toolkit.html.twig', [ - 'components' => $registry->all(), - 'package' => $package, - ]); - } - - #[Route('/components/{currentComponent}', name: 'app_toolkit_components', defaults: ['currentComponent' => ''])] - public function components( - UxPackageRepository $packageRepository, - ToolkitComponentService $toolkitComponentService, - string $currentComponent - ): Response - { - $package = $packageRepository->find('toolkit'); - $registry = $toolkitComponentService->getRegistry(); - - $component = $registry->get($currentComponent); - if (null == $component) { - // get the first non-example component - $components = array_filter($registry->all(), function ($component) { - if($component->type !== RegistryItemType::Component) { - return null; - } - - return $component; - }); - $component = reset($components); - - - } - - if (null === $component) { - throw $this->createNotFoundException('No component found'); - } + $demoPreviewHeight = '400px'; + $demoPreviewUrl = $uriSigner->sign($this->generateUrl('app_toolkit_component_preview', [ + 'toolkitKit' => ToolkitKit::Shadcn->value, + 'code' => <<<'TWIG' + + + Symfony is cool + + Symfony is a set of reusable PHP components... + + + + ... and a PHP framework for web projects + + + + Visit symfony.com + + + + TWIG, + 'height' => $demoPreviewHeight, + ], UrlGeneratorInterface::ABSOLUTE_URL)); - // get all examples for this component - $examples = array_filter($registry->all(), function ($component) use ($currentComponent) { - if($component->type !== RegistryItemType::Example) { - return null; - } - - if ($component->parentName !== $currentComponent) { - return null; - } - - return $component; - }); - - return $this->render('ux_packages/toolkit/components.html.twig', [ - 'components' => $registry->all(), - 'currentComponent' => $component, - 'examples' => $examples, + return $this->render('ux_packages/toolkit.html.twig', [ 'package' => $package, + 'kits' => ToolkitKit::cases(), + 'demoPreviewUrl' => $demoPreviewUrl, + 'demoPreviewHeight' => $demoPreviewHeight, ]); } - - #[Route('/components/{currentComponent}/preview', name: 'app_toolkit_component_preview')] - public function componentPreview( - ToolkitComponentService $toolkitComponentService, - string $currentComponent, - ): Response - { - $currentComponentFromRegistry = $toolkitComponentService->getRegistry()->get($currentComponent, RegistryItemType::Example); - if (null === $currentComponentFromRegistry) { - throw $this->createNotFoundException('Example not found'); - } - - $html = $toolkitComponentService->preview($currentComponentFromRegistry->name, RegistryItemType::Example); - return $this->render('ux_packages/toolkit/preview.html.twig', [ - 'component' => $currentComponentFromRegistry, - 'html' => $html, - // in the future, we'll change the framework dynamically - 'cssFramework' => 'tailwindcss', - ]); - } - } diff --git a/ux.symfony.com/src/Enum/ToolkitKit.php b/ux.symfony.com/src/Enum/ToolkitKit.php new file mode 100644 index 00000000000..7a90a576d66 --- /dev/null +++ b/ux.symfony.com/src/Enum/ToolkitKit.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Enum; + +/** + * For convenience and performance, official UX Toolkit kits are hardcoded. + * + * @internal + * + * @author Hugo Alliaume + */ +enum ToolkitKit: string +{ + case Shadcn = 'shadcn'; + + public function getHumanName(): string + { + return match ($this) { + self::Shadcn => 'Shadcn UI', + }; + } + + public function getDescription(): string + { + return match ($this) { + self::Shadcn => 'Component based on the Shadcn UI library, one of the most popular design systems in JavaScript world.', + }; + } + + public function getDescriptionForComponent(string $component): string + { + return match ($this) { + self::Shadcn => 'Component "'.$component.'" based on the Shadcn UI library, one of the most popular design systems in JavaScript world.', + }; + } + + public function getIcon(): string + { + return match ($this) { + self::Shadcn => 'simple-icons:shadcnui', + }; + } + + public function getGettingStarted(): string + { + return match ($this) { + self::Shadcn => <<onlyInDevMode) { + if ($this->isDevDependency) { return 'composer require --dev '.$this->getComposerName(); } - + return 'composer require '.$this->getComposerName(); } diff --git a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php index 4313b26263d..4da4c02d84a 100644 --- a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php +++ b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php @@ -11,12 +11,15 @@ namespace App\Service\CommonMark; +use App\Service\CommonMark\Extension\CodeBlockRenderer\CodeBlockRenderer; use League\CommonMark\CommonMarkConverter; +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Extension\ExternalLink\ExternalLinkExtension; use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\Mention\MentionExtension; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Tempest\Highlight\CommonMark\HighlightExtension; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * @author Kevin Bond @@ -24,6 +27,12 @@ #[AsDecorator('twig.markdown.league_common_mark_converter_factory')] final class ConverterFactory { + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly UriSigner $uriSigner, + ) { + } + public function __invoke(): CommonMarkConverter { $converter = new CommonMarkConverter([ @@ -47,8 +56,8 @@ public function __invoke(): CommonMarkConverter $converter->getEnvironment() ->addExtension(new ExternalLinkExtension()) ->addExtension(new MentionExtension()) - ->addExtension(new HighlightExtension()) ->addExtension(new FrontMatterExtension()) + ->addRenderer(FencedCode::class, new CodeBlockRenderer($this->urlGenerator, $this->uriSigner)) ; return $converter; diff --git a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php new file mode 100644 index 00000000000..27f2354cc72 --- /dev/null +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service\CommonMark\Extension\CodeBlockRenderer; + +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Tempest\Highlight\Highlighter; +use Tempest\Highlight\WebTheme; + +final readonly class CodeBlockRenderer implements NodeRendererInterface +{ + public function __construct( + private UrlGeneratorInterface $urlGenerator, + private UriSigner $uriSigner, + ) { + } + + #[\Override] + public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable|string|null + { + if (!$node instanceof FencedCode) { + throw new \InvalidArgumentException('Block must be instance of '.FencedCode::class); + } + + $infoWords = $node->getInfoWords(); + $language = $infoWords[0] ?? 'txt'; + $options = isset($infoWords[1]) && json_validate($infoWords[1]) ? json_decode($infoWords[1], true) : []; + $preview = $options['preview'] ?? false; + $kit = $options['kit'] ?? null; + $height = $options['height'] ?? '150px'; + + $code = $node->getLiteral(); + + $output = $this->highlightCode($code, $language); + + if ($preview && $kit) { + $previewUrl = $this->uriSigner->sign($this->urlGenerator->generate('app_toolkit_component_preview', [ + 'toolkitKit' => $kit, + 'code' => $code, + 'height' => $height, + ], UrlGeneratorInterface::ABSOLUTE_URL)); + + $output = << +

    +
    +
    +
    + + Loading... +
    + +
    +
    {$output}
    +
    +
    +HTML; + } + + return $output; + } + + private function highlightCode(string $code, string $language): string + { + $highlighter = new Highlighter(); + + $theme = $highlighter->getTheme(); + $parsed = $highlighter->parse($code, $language); + $output = $theme instanceof WebTheme + ? $theme->preBefore($highlighter).$parsed.$theme->preAfter($highlighter) + : '
    '.$parsed.'
    '; + + return << +
    +
    + {$output} +
    +
    +
    + HTML; + } +} diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php new file mode 100644 index 00000000000..5fc07aa42db --- /dev/null +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service\Toolkit; + +use App\Enum\ToolkitKit; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +class ToolkitService +{ + public function __construct( + #[Autowire(service: 'ux_toolkit.registry.factory')] + private RegistryFactory $registryFactory, + ) { + } + + /** + * @return Component[] + */ + public function getDocumentableComponents(ToolkitKit $kit): array + { + return array_filter($this->getKit($kit)->getComponents(), fn (Component $component) => $component->doc); + } + + public function getComponent(ToolkitKit $kit, string $component): ?Component + { + return $this->getKit($kit)->getComponent($component); + } + + public function getKit(ToolkitKit $kit): Kit + { + return $this->getKits()[$kit->value] ?? throw new \InvalidArgumentException(\sprintf('Kit "%s" not found', $kit->value)); + } + + /** + * @return array + */ + public function getKits(): array + { + static $kits = null; + + if (null === $kits) { + $kits = []; + foreach (ToolkitKit::cases() as $kit) { + $kits[$kit->value] = $this->registryFactory->getForKit($kit->value)->getKit($kit->value); + } + } + + return $kits; + } +} diff --git a/ux.symfony.com/src/Service/ToolkitComponentService.php b/ux.symfony.com/src/Service/ToolkitComponentService.php deleted file mode 100644 index 7e44b3b348f..00000000000 --- a/ux.symfony.com/src/Service/ToolkitComponentService.php +++ /dev/null @@ -1,140 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\Service; - -use Symfony\Component\DependencyInjection\Argument\ServiceLocator; -use Symfony\Component\EventDispatcher\EventDispatcher; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Finder\Finder; -use Symfony\Component\PropertyAccess\PropertyAccessor; -use Symfony\UX\Toolkit\Registry\Registry; -use Symfony\UX\Toolkit\Registry\RegistryFactory; -use Symfony\UX\Toolkit\Registry\RegistryItem; -use Symfony\UX\Toolkit\Registry\RegistryItemType; -use Symfony\UX\TwigComponent\ComponentFactory; -use Symfony\UX\TwigComponent\ComponentProperties; -use Symfony\UX\TwigComponent\ComponentRenderer; -use Symfony\UX\TwigComponent\ComponentStack; -use Symfony\UX\TwigComponent\ComponentTemplateFinder; -use Symfony\UX\TwigComponent\DependencyInjection\TwigComponentExtension; -use Twig\Environment; - -final class ToolkitComponentService -{ - public function __construct( - private readonly RegistryFactory $registryFactory, - private readonly Environment $twig, - private readonly string $manifest = __DIR__ . '/../../vendor/symfony/ux-toolkit/registry/default', - ) {} - - - public function getRegistry(): Registry - { - return $this->registryFactory->create((new Finder())->files()->in($this->manifest)); - } - - public function get(string $componentName): ?RegistryItem - { - $registry = $this->getRegistry(); - - foreach ($registry->all() as $component) { - if ($component->name === $componentName && $component->type === RegistryItemType::Component) { - return $component; - } - } - - return null; - } - - private function getWorkdir(): string { - - // Put the code in a temporary file. We can image in the future use another way to pass the code to the component, - // but today the ComponentFactory need a folder - // We do it only once, for all visitors - $filesystem = new Filesystem(); - $workdir = sys_get_temp_dir() . '/uxcomponent'; - if (!$filesystem->exists($workdir)) { - $filesystem->mkdir($workdir); - - // we put all components in this folder - foreach ($this->getRegistry()->all() as $component) { - $componentFile = $workdir . '/' . $component->name . '.twig'; - file_put_contents($componentFile, $component->code); - } - } - - return $workdir; - } - - public function getComponentTwigPath(string $componentName): string - { - return $this->getWorkdir() . '/' . $componentName . '.twig'; - } - - /** - * This method allow to render a dynamic component, without using the auto-wired TwigComponentExtension - * - * Actually, the extension compile components. If we want to render a dynamic component, we need to do it manually - * This should be improved in the future. - * - * @param string $componentName - * @return string - */ - public function preview(string $componentName, RegistryItemType $type = RegistryItemType::Component): string - { - $registry = $this->getRegistry(); - if (!$registry->has($componentName, $type)) { - throw new \InvalidArgumentException(sprintf('Component "%s" not found.', $componentName)); - } - - $currentComponentFromRegistry = $registry->get($componentName, $type); - - - - // Add this path to twig - $loader = $this->twig->getLoader(); - $loader->addPath($this->getWorkdir()); - - // Component finder use compiled values. We need to construct new one - $componentFactory = new ComponentFactory( - new ComponentTemplateFinder($this->twig), - new ServiceLocator(function() { - return []; - }, []), - new PropertyAccessor(), - new EventDispatcher(), - [], - [], - ); - - if ($type === RegistryItemType::Component) { - // Preview a component - $mounted = $componentFactory->create($currentComponentFromRegistry->name); - - $componentProperties = new ComponentProperties(new PropertyAccessor()); - $componentStack = new ComponentStack(); - - $renderer = new ComponentRenderer( - $this->twig, - new EventDispatcher(), - $componentFactory, - $componentProperties, - $componentStack, - ); - - return $renderer->render($mounted); - } - - // Preview an example - return $this->twig->render($currentComponentFromRegistry->name . '.twig'); - } -} diff --git a/ux.symfony.com/src/Service/UxPackageRepository.php b/ux.symfony.com/src/Service/UxPackageRepository.php index f95c0507692..d488ea98532 100644 --- a/ux.symfony.com/src/Service/UxPackageRepository.php +++ b/ux.symfony.com/src/Service/UxPackageRepository.php @@ -235,8 +235,8 @@ public function findAll(?string $query = null): array 'toolkit', 'Toolkit', 'app_toolkit', - '#BE0404', - 'linear-gradient(142deg,rgb(60, 230, 236) -15%,rgb(77, 97, 214) 95%)', + '#4c5dc1', + 'linear-gradient(142deg, #031213 -15%, #4c5dc1 95%)', 'Build your Design System.', 'Collection of components and templates that you can use to build your pages.', null, diff --git a/ux.symfony.com/src/Twig/Components/Code/CodeBlock.php b/ux.symfony.com/src/Twig/Components/Code/CodeBlock.php index 3ecc2a48e63..ec4db5ed134 100644 --- a/ux.symfony.com/src/Twig/Components/Code/CodeBlock.php +++ b/ux.symfony.com/src/Twig/Components/Code/CodeBlock.php @@ -88,6 +88,8 @@ public function prepareSource(): array $content = $this->getRawSource(); if ('php' === $this->getLanguage()) { $content = SourceCleaner::cleanupPhpFile($content); + } elseif ('twig' === $this->getLanguage()) { + $content = SourceCleaner::cleanupTwigFile($content); } return $this->splitAndProcessSource($content); @@ -220,7 +222,7 @@ private function splitAndProcessSource(string $content): array // the use statements + surrounding span $parts[] = [ - 'content' => ' + 'content' => '
    // ... use statements hidden - click to show
    ', 'highlight' => false, ]; diff --git a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php new file mode 100644 index 00000000000..d97706fae64 --- /dev/null +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\Components\Toolkit; + +use App\Enum\ToolkitKit; +use App\Util\SourceCleaner; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\String\AbstractString; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Tempest\Highlight\Highlighter; + +use function Symfony\Component\String\s; + +#[AsTwigComponent] +class ComponentDoc +{ + public ToolkitKit $kit; + public Component $component; + + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly UriSigner $uriSigner, + private readonly Highlighter $highlighter, + private readonly \Twig\Environment $twig, + ) { + } + + public function getContent(): string + { + return $this->formatContent($this->component->doc->markdownContent); + } + + private function formatContent(string $markdownContent): string + { + $markdownContent = s($markdownContent); + + $markdownContent = $this->insertInstallation($markdownContent); + $markdownContent = $this->insertUsage($markdownContent); + $markdownContent = $this->adaptPreviewableCodeBlocks($markdownContent); + + return $markdownContent; + } + + private function insertInstallation(AbstractString $markdownContent): AbstractString + { + $installationCode = SourceCleaner::processTerminalLines(<<component->name} +# or if you already use another kit +symfony console ux:toolkit:install-component {$this->component->name} --kit {$this->kit->value} +SHELL + ); + + return $markdownContent->replace( + '', + << +
    +
    +
    {$installationCode}
    +
    +
    + + HTML + ); + } + + private function insertUsage(AbstractString $markdownContent): AbstractString + { + $firstTwigPreviewBlock = $markdownContent->match('/```twig.*?\n(.+?)```/s'); + $firstTwigPreviewBlock = $firstTwigPreviewBlock ? trim($firstTwigPreviewBlock[1]) : ''; + + return $markdownContent->replace( + '', + '```twig'."\n".$firstTwigPreviewBlock."\n".'```' + ); + } + + private function adaptPreviewableCodeBlocks(AbstractString $markdownContent): AbstractString + { + return $markdownContent->replaceMatches('/```(?P[a-z]+) +(?P\{.+?\})\n/', function (array $matches) { + $lang = $matches['lang']; + $options = json_decode($matches['options'], true, flags: \JSON_THROW_ON_ERROR); + + if ($options['preview'] ?? false) { + $options['kit'] = $this->kit->value; + } + + return \sprintf('```%s %s'."\n", $lang, json_encode($options, \JSON_THROW_ON_ERROR)); + }); + } +} diff --git a/ux.symfony.com/src/Util/SourceCleaner.php b/ux.symfony.com/src/Util/SourceCleaner.php index 0b70cc794d0..7d11299006e 100644 --- a/ux.symfony.com/src/Util/SourceCleaner.php +++ b/ux.symfony.com/src/Util/SourceCleaner.php @@ -46,6 +46,14 @@ public static function cleanupPhpFile(string $contents, bool $removeClass = fals return $contents->trim()->toString(); } + public static function cleanupTwigFile(string $contents): string + { + // Remove "Toolkit:$themeName:" prefix + $contents = u($contents)->replaceMatches('/Toolkit:.+?:/', ''); + + return $contents->trim()->toString(); + } + public static function processTerminalLines(string $content): string { $lines = explode("\n", $content); @@ -57,12 +65,17 @@ public static function processTerminalLines(string $content): string return ''; } + // command output + if (str_starts_with($line, '>')) { + return preg_replace('/^>\s+/m', '', $line); + } + // comment lines - if (str_starts_with($line, '//')) { - return \sprintf('%s', $line); + if (str_starts_with($line, '//') || str_starts_with($line, '#')) { + return \sprintf('%s', $line); } - return '$ '.$line; + return '$ '.$line; }, $lines); return trim(implode("\n", $lines)); diff --git a/ux.symfony.com/symfony.lock b/ux.symfony.com/symfony.lock index 54aa7cd1d56..2164807c8da 100644 --- a/ux.symfony.com/symfony.lock +++ b/ux.symfony.com/symfony.lock @@ -611,6 +611,9 @@ "symfony/ux-toggle-password": { "version": "2.x-dev" }, + "symfony/ux-toolkit": { + "version": "2.x-dev" + }, "symfony/ux-translator": { "version": "2.9999999", "recipe": { @@ -678,6 +681,18 @@ "symfonycasts/sass-bundle": { "version": "v0.1.0" }, + "symfonycasts/tailwind-bundle": { + "version": "0.9", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.8", + "ref": "4ea7c9488fdce8943520daf3fdc31e93e5b59c64" + }, + "files": [ + "config/packages/symfonycasts_tailwind.yaml" + ] + }, "tales-from-a-dev/twig-tailwind-extra": { "version": "0.3", "recipe": { diff --git a/ux.symfony.com/templates/_header.html.twig b/ux.symfony.com/templates/_header.html.twig index c0ce67fb6c0..dc16629ea38 100644 --- a/ux.symfony.com/templates/_header.html.twig +++ b/ux.symfony.com/templates/_header.html.twig @@ -46,16 +46,17 @@ diff --git a/ux.symfony.com/templates/components/Alert.html.twig b/ux.symfony.com/templates/components/Alert.html.twig index 51248f49110..66bb8e1c7ef 100644 --- a/ux.symfony.com/templates/components/Alert.html.twig +++ b/ux.symfony.com/templates/components/Alert.html.twig @@ -1,6 +1,4 @@ -
    - Dependency test - {% block content %}Alert{% endblock %} + diff --git a/ux.symfony.com/templates/components/AspectRatio.html.twig b/ux.symfony.com/templates/components/AspectRatio.html.twig deleted file mode 100644 index 0d81a141d83..00000000000 --- a/ux.symfony.com/templates/components/AspectRatio.html.twig +++ /dev/null @@ -1,9 +0,0 @@ -{%- props ratio = (4/3) -%} -
    -
    - {% block content %}{% endblock %} -
    -
    diff --git a/ux.symfony.com/templates/components/Avatar.html.twig b/ux.symfony.com/templates/components/Avatar.html.twig deleted file mode 100644 index f7d1272ad59..00000000000 --- a/ux.symfony.com/templates/components/Avatar.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Avatar/AvatarFallback.html.twig b/ux.symfony.com/templates/components/Avatar/AvatarFallback.html.twig deleted file mode 100644 index 2b02b8ef910..00000000000 --- a/ux.symfony.com/templates/components/Avatar/AvatarFallback.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex h-full w-full items-center justify-center rounded-full bg-muted', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Avatar/AvatarImage.html.twig b/ux.symfony.com/templates/components/Avatar/AvatarImage.html.twig deleted file mode 100644 index 7d1f9fc99f2..00000000000 --- a/ux.symfony.com/templates/components/Avatar/AvatarImage.html.twig +++ /dev/null @@ -1,11 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'aspect-square h-full w-full', - variants: {}, - compoundVariants: [] -) -%} - - \ No newline at end of file diff --git a/ux.symfony.com/templates/components/AvatarFallback.html.twig b/ux.symfony.com/templates/components/AvatarFallback.html.twig deleted file mode 100644 index 2b02b8ef910..00000000000 --- a/ux.symfony.com/templates/components/AvatarFallback.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex h-full w-full items-center justify-center rounded-full bg-muted', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/AvatarImage.html.twig b/ux.symfony.com/templates/components/AvatarImage.html.twig deleted file mode 100644 index 7d1f9fc99f2..00000000000 --- a/ux.symfony.com/templates/components/AvatarImage.html.twig +++ /dev/null @@ -1,11 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'aspect-square h-full w-full', - variants: {}, - compoundVariants: [] -) -%} - - \ No newline at end of file diff --git a/ux.symfony.com/templates/components/Badge.html.twig b/ux.symfony.com/templates/components/Badge.html.twig index eab142dd5d1..5668125b579 100644 --- a/ux.symfony.com/templates/components/Badge.html.twig +++ b/ux.symfony.com/templates/components/Badge.html.twig @@ -1,34 +1,19 @@ -{%- props variant = 'default', outline = false -%} -{%- set style = html_cva( - base: 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', - variants: { - variant: { - default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - }, - outline: { - true: "text-foreground bg-white", - } - }, - compoundVariants: [{ - variant: ['default'], - outline: ['true'], - class: 'border-primary', - }, { - variant: ['secondary'], - outline: ['true'], - class: 'border-secondary', - }, { - variant: ['destructive'], - outline: ['true'], - class: 'border-destructive', - },] -) -%} - -
    - {% block content %}{% endblock %} +
    + + {%- if icon %} + + {% endif -%} + {{- label -}} + + + {%- if url %} + + {{- value -}} + + {% else %} + {{ value }} + {% endif -%} +
    diff --git a/ux.symfony.com/templates/components/Blank.html.twig b/ux.symfony.com/templates/components/Blank.html.twig deleted file mode 100644 index c43950b7f65..00000000000 --- a/ux.symfony.com/templates/components/Blank.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: '', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Breadcrumb.html.twig b/ux.symfony.com/templates/components/Breadcrumb.html.twig deleted file mode 100644 index c43950b7f65..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: '', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbEllipsis.html.twig b/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbEllipsis.html.twig deleted file mode 100644 index caf80630294..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbEllipsis.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex h-9 w-9 items-center justify-center', - variants: {}, - compoundVariants: [] -) -%} - - diff --git a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbItem.html.twig b/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbItem.html.twig deleted file mode 100644 index 33d43018aeb..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbItem.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'inline-flex items-center gap-1.5', - variants: {}, - compoundVariants: [] -) -%} - -
  • - {% block content %}{% endblock %} -
  • diff --git a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbLink.html.twig b/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbLink.html.twig deleted file mode 100644 index 1a61473114a..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbLink.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'transition-colors hover:text-foreground', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbList.html.twig b/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbList.html.twig deleted file mode 100644 index 9d3e0809ee7..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbList.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', - variants: {}, - compoundVariants: [] -) -%} - -
      - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbPage.html.twig b/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbPage.html.twig deleted file mode 100644 index 760fdfdb5e4..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbPage.html.twig +++ /dev/null @@ -1,18 +0,0 @@ -{%- props - disabled = false --%} -{%- set style = html_cva( - base: 'font-normal text-foreground', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbSeparator.html.twig b/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbSeparator.html.twig deleted file mode 100644 index 989947184d4..00000000000 --- a/ux.symfony.com/templates/components/Breadcrumb/BreadcrumbSeparator.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: '[&>svg]:w-3.5 [&>svg]:h-3.5', - variants: {}, - compoundVariants: [] -) -%} - - diff --git a/ux.symfony.com/templates/components/BreadcrumbEllipsis.html.twig b/ux.symfony.com/templates/components/BreadcrumbEllipsis.html.twig deleted file mode 100644 index caf80630294..00000000000 --- a/ux.symfony.com/templates/components/BreadcrumbEllipsis.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex h-9 w-9 items-center justify-center', - variants: {}, - compoundVariants: [] -) -%} - - diff --git a/ux.symfony.com/templates/components/BreadcrumbItem.html.twig b/ux.symfony.com/templates/components/BreadcrumbItem.html.twig deleted file mode 100644 index 33d43018aeb..00000000000 --- a/ux.symfony.com/templates/components/BreadcrumbItem.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'inline-flex items-center gap-1.5', - variants: {}, - compoundVariants: [] -) -%} - -
  • - {% block content %}{% endblock %} -
  • diff --git a/ux.symfony.com/templates/components/BreadcrumbLink.html.twig b/ux.symfony.com/templates/components/BreadcrumbLink.html.twig deleted file mode 100644 index 1a61473114a..00000000000 --- a/ux.symfony.com/templates/components/BreadcrumbLink.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'transition-colors hover:text-foreground', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/BreadcrumbList.html.twig b/ux.symfony.com/templates/components/BreadcrumbList.html.twig deleted file mode 100644 index 9d3e0809ee7..00000000000 --- a/ux.symfony.com/templates/components/BreadcrumbList.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', - variants: {}, - compoundVariants: [] -) -%} - -
      - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/BreadcrumbPage.html.twig b/ux.symfony.com/templates/components/BreadcrumbPage.html.twig deleted file mode 100644 index 760fdfdb5e4..00000000000 --- a/ux.symfony.com/templates/components/BreadcrumbPage.html.twig +++ /dev/null @@ -1,18 +0,0 @@ -{%- props - disabled = false --%} -{%- set style = html_cva( - base: 'font-normal text-foreground', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/BreadcrumbSeparator.html.twig b/ux.symfony.com/templates/components/BreadcrumbSeparator.html.twig deleted file mode 100644 index 989947184d4..00000000000 --- a/ux.symfony.com/templates/components/BreadcrumbSeparator.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: '[&>svg]:w-3.5 [&>svg]:h-3.5', - variants: {}, - compoundVariants: [] -) -%} - - diff --git a/ux.symfony.com/templates/components/Button.html.twig b/ux.symfony.com/templates/components/Button.html.twig index 9927ba4c2db..ec285d78418 100644 --- a/ux.symfony.com/templates/components/Button.html.twig +++ b/ux.symfony.com/templates/components/Button.html.twig @@ -3,20 +3,20 @@ base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', }, outline: { - true: "text-foreground bg-white", + true: 'text-foreground bg-white', }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', }, }, compoundVariants: [{ @@ -31,7 +31,7 @@ variant: ['destructive'], outline: ['true'], class: 'border-destructive', - },] + }] ) -%}
    +{% props name, image, url, description, tags, lazyload = true %} + +
    + +
    + {{ name }} +
    + +
    +

    + + {{- name -}} + +

    +

    + {{- description -}} +

    +

    + {% for tag in tags %} + {{ tag }} + {% endfor %} +

    +
    + +
    diff --git a/ux.symfony.com/templates/components/Card/CardContent.html.twig b/ux.symfony.com/templates/components/Card/CardContent.html.twig deleted file mode 100644 index c54f24197d3..00000000000 --- a/ux.symfony.com/templates/components/Card/CardContent.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'p-6 pt-0', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Card/CardDescription.html.twig b/ux.symfony.com/templates/components/Card/CardDescription.html.twig deleted file mode 100644 index cdd70d9422a..00000000000 --- a/ux.symfony.com/templates/components/Card/CardDescription.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'text-sm text-muted-foreground', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Card/CardFooter.html.twig b/ux.symfony.com/templates/components/Card/CardFooter.html.twig deleted file mode 100644 index 1325d35311b..00000000000 --- a/ux.symfony.com/templates/components/Card/CardFooter.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex items-center p-6 pt-0', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Card/CardHeader.html.twig b/ux.symfony.com/templates/components/Card/CardHeader.html.twig deleted file mode 100644 index fb96700289b..00000000000 --- a/ux.symfony.com/templates/components/Card/CardHeader.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex flex-col space-y-1.5 p-6', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Card/CardTitle.html.twig b/ux.symfony.com/templates/components/Card/CardTitle.html.twig deleted file mode 100644 index c6decf07ea4..00000000000 --- a/ux.symfony.com/templates/components/Card/CardTitle.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'font-semibold leading-none tracking-tight', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/CardContent.html.twig b/ux.symfony.com/templates/components/CardContent.html.twig deleted file mode 100644 index c54f24197d3..00000000000 --- a/ux.symfony.com/templates/components/CardContent.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'p-6 pt-0', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/CardDescription.html.twig b/ux.symfony.com/templates/components/CardDescription.html.twig deleted file mode 100644 index cdd70d9422a..00000000000 --- a/ux.symfony.com/templates/components/CardDescription.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'text-sm text-muted-foreground', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/CardFooter.html.twig b/ux.symfony.com/templates/components/CardFooter.html.twig deleted file mode 100644 index 1325d35311b..00000000000 --- a/ux.symfony.com/templates/components/CardFooter.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex items-center p-6 pt-0', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/CardHeader.html.twig b/ux.symfony.com/templates/components/CardHeader.html.twig deleted file mode 100644 index fb96700289b..00000000000 --- a/ux.symfony.com/templates/components/CardHeader.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'flex flex-col space-y-1.5 p-6', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/CardTitle.html.twig b/ux.symfony.com/templates/components/CardTitle.html.twig deleted file mode 100644 index c6decf07ea4..00000000000 --- a/ux.symfony.com/templates/components/CardTitle.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'font-semibold leading-none tracking-tight', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig b/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig index b6ea35ae5b6..d2ba664ebb7 100644 --- a/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig +++ b/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig @@ -1,5 +1,5 @@ -{% props - code = '', +{% props + code = '', language = 'twig', highlight = true, %} diff --git a/ux.symfony.com/templates/components/Grid.html.twig b/ux.symfony.com/templates/components/Grid.html.twig deleted file mode 100644 index 66ecaef651a..00000000000 --- a/ux.symfony.com/templates/components/Grid.html.twig +++ /dev/null @@ -1,68 +0,0 @@ -{%- props - xs = 0, - sm = 0, - md = 0, - lg = 0, - xl = 0, - align = '', - justify = '', - direction = '', - gap = 0, --%} - -{% set xsSize = false %}{% if xs > 0 %}{% set xsSize = "true" %}{% endif %} -{% set smSize = false %}{% if sm > 0 %}{% set smSize = "true" %}{% endif %} -{% set mdSize = false %}{% if md > 0 %}{% set mdSize = "true" %}{% endif %} -{% set lgSize = false %}{% if lg > 0 %}{% set lgSize = "true" %}{% endif %} -{% set xlSize = false %}{% if xl > 0 %}{% set xlSize = "true" %}{% endif %} - -{% set alignClass = '' %} -{% if align == 'start' %}{% set alignClass = 'items-start' %} -{% elseif align == 'center' %}{% set alignClass = 'items-center' %} -{% elseif align == 'end' %}{% set alignClass = 'items-end' %} -{% endif %} - -{% set justifyClass = '' %} -{% if justify == 'start' %}{% set justifyClass = 'justify-start' %} -{% elseif justify == 'center' %}{% set justifyClass = 'justify-center' %} -{% elseif justify == 'end' %}{% set justifyClass = 'justify-end' %} -{% elseif justify == 'between' %}{% set justifyClass = 'justify-between' %} -{% elseif justify == 'around' %}{% set justifyClass = 'justify-around' %} -{% elseif justify == 'evenly' %}{% set justifyClass = 'justify-evenly' %} -{% endif %} - -{% set directionClass = '' %} -{% if direction == 'row' %}{% set directionClass = 'flex-row' %} -{% elseif direction == 'column' %}{% set directionClass = 'flex-col' %} -{% endif %} - -{% set gapClass = '' %} -{% if gap > 0 %} - {% set gapClass = 'gap-' ~ gap %} -{% endif %} -{%- set style = html_cva( - base: 'grid', - variants: { - xsSize: { - "true": 'grid-cols-' ~ xs, - }, - smSize: { - "true": 'sm:grid-cols-' ~ sm, - }, - mdSize: { - "true": 'md:grid-cols-' ~ md, - }, - lgSize: { - "true": 'lg:grid-cols-' ~ lg, - }, - xlSize: { - "true": 'xl:grid-cols-' ~ xl, - }, - }, -) -%} -
    - {% block content %}{% endblock %} -
    \ No newline at end of file diff --git a/ux.symfony.com/templates/components/Grid/Col.html.twig b/ux.symfony.com/templates/components/Grid/Col.html.twig deleted file mode 100644 index 0a263303fba..00000000000 --- a/ux.symfony.com/templates/components/Grid/Col.html.twig +++ /dev/null @@ -1,77 +0,0 @@ -{%- props - xs = 0, - sm = 0, - md = 0, - lg = 0, - xl = 0, - xsOffset = 0, - smOffset = 0, - mdOffset = 0, - lgOffset = 0, - xlOffset = 0, - first = false, - last = false, --%} - -{% set xsCols = false %}{% if xs > 0 %}{% set xsCols = "true" %}{% endif %} -{% set smCols = false %}{% if sm > 0 %}{% set smCols = "true" %}{% endif %} -{% set mdCols = false %}{% if md > 0 %}{% set mdCols = "true" %}{% endif %} -{% set lgCols = false %}{% if lg > 0 %}{% set lgCols = "true" %}{% endif %} -{% set xlCols = false %}{% if xl > 0 %}{% set xlCols = "true" %}{% endif %} - -{% set xsOffsetCols = false %}{% if xsOffset > 0 %}{% set xsOffsetCols = "true" %}{% endif %} -{% set smOffsetCols = false %}{% if smOffset > 0 %}{% set smOffsetCols = "true" %}{% endif %} -{% set mdOffsetCols = false %}{% if mdOffset > 0 %}{% set mdOffsetCols = "true" %}{% endif %} -{% set lgOffsetCols = false %}{% if lgOffset > 0 %}{% set lgOffsetCols = "true" %}{% endif %} -{% set xlOffsetCols = false %}{% if xlOffset > 0 %}{% set xlOffsetCols = "true" %}{% endif %} - -{% set firstClass = false %}{% if first %}{% set firstClass = "true" %}{% endif %} -{% set lastClass = false %}{% if last %}{% set lastClass = "true" %}{% endif %} - -{%- set style = html_cva( - base: '', - variants: { - xsCols: { - "true": 'col-span-' ~ xs, - }, - smCols: { - "true": 'sm:col-span-' ~ sm, - }, - mdCols: { - "true": 'md:col-span-' ~ md, - }, - lgCols: { - "true": 'lg:col-span-' ~ lg, - }, - xlCols: { - "true": 'xl:col-span-' ~ xl, - }, - xsOffsetCols: { - "true": 'col-start-' ~ (xsOffset + 1), - }, - smOffsetCols: { - "true": 'sm:col-start-' ~ (smOffset + 1), - }, - mdOffsetCols: { - "true": 'md:col-start-' ~ (mdOffset + 1), - }, - lgOffsetCols: { - "true": 'lg:col-start-' ~ (lgOffset + 1), - }, - xlOffsetCols: { - "true": 'xl:col-start-' ~ (xlOffset + 1), - }, - firstClass: { - "true": 'order-first', - }, - lastClass: { - "true": 'order-last', - }, - }, -) -%} -
    - {% block content %}{% endblock %} -
    \ No newline at end of file diff --git a/ux.symfony.com/templates/components/Grid/Row.html.twig b/ux.symfony.com/templates/components/Grid/Row.html.twig deleted file mode 100644 index 9ebef53f7f5..00000000000 --- a/ux.symfony.com/templates/components/Grid/Row.html.twig +++ /dev/null @@ -1,60 +0,0 @@ -{%- props - start = 'auto', - center = false, - end = false, - top = false, - middle = false, - bottom = false, - around = false, - between = false, - reverse = false, --%} - -{% set startClass = false %}{% if start != 'auto' %}{% set startClass = "true" %}{% endif %} -{% set centerClass = false %}{% if center %}{% set centerClass = "true" %}{% endif %} -{% set endClass = false %}{% if end %}{% set endClass = "true" %}{% endif %} -{% set topClass = false %}{% if top %}{% set topClass = "true" %}{% endif %} -{% set middleClass = false %}{% if middle %}{% set middleClass = "true" %}{% endif %} -{% set bottomClass = false %}{% if bottom %}{% set bottomClass = "true" %}{% endif %} -{% set aroundClass = false %}{% if around %}{% set aroundClass = "true" %}{% endif %} -{% set betweenClass = false %}{% if between %}{% set betweenClass = "true" %}{% endif %} -{% set reverseClass = false %}{% if reverse %}{% set reverseClass = "true" %}{% endif %} - -{%- set style = html_cva( - base: 'flex flex-wrap', - variants: { - startClass: { - "true": 'justify-start', - }, - centerClass: { - "true": 'justify-center', - }, - endClass: { - "true": 'justify-end', - }, - topClass: { - "true": 'items-start', - }, - middleClass: { - "true": 'items-center', - }, - bottomClass: { - "true": 'items-end', - }, - aroundClass: { - "true": 'justify-around', - }, - betweenClass: { - "true": 'justify-between', - }, - reverseClass: { - "true": 'flex-row-reverse', - }, - }, -) -%} -
    - {% block content %}{% endblock %} -
    \ No newline at end of file diff --git a/ux.symfony.com/templates/components/Navbar.html.twig b/ux.symfony.com/templates/components/Navbar.html.twig deleted file mode 100644 index 60a10b244d2..00000000000 --- a/ux.symfony.com/templates/components/Navbar.html.twig +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/ux.symfony.com/templates/components/Package/PackageHeader.html.twig b/ux.symfony.com/templates/components/Package/PackageHeader.html.twig index 69e1bbc6f4b..a66eea0fb6a 100644 --- a/ux.symfony.com/templates/components/Package/PackageHeader.html.twig +++ b/ux.symfony.com/templates/components/Package/PackageHeader.html.twig @@ -27,16 +27,6 @@ title="Read the docs" url="{{ package.officialDocsUrl }}" /> - - {% if packageBrowseUrl is defined %} - - {% endif %}
    diff --git a/ux.symfony.com/templates/components/Table/TableBody.html.twig b/ux.symfony.com/templates/components/Table/TableBody.html.twig deleted file mode 100644 index 3ed0f02312b..00000000000 --- a/ux.symfony.com/templates/components/Table/TableBody.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: '[&_tr:last-child]:border-0', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Table/TableCaption.html.twig b/ux.symfony.com/templates/components/Table/TableCaption.html.twig deleted file mode 100644 index def36aac655..00000000000 --- a/ux.symfony.com/templates/components/Table/TableCaption.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'text-muted-foreground mt-4 text-sm', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Table/TableCell.html.twig b/ux.symfony.com/templates/components/Table/TableCell.html.twig deleted file mode 100644 index db584a4477e..00000000000 --- a/ux.symfony.com/templates/components/Table/TableCell.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Table/TableFooter.html.twig b/ux.symfony.com/templates/components/Table/TableFooter.html.twig deleted file mode 100644 index cbd36d1e6e9..00000000000 --- a/ux.symfony.com/templates/components/Table/TableFooter.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Table/TableHead.html.twig b/ux.symfony.com/templates/components/Table/TableHead.html.twig deleted file mode 100644 index 73e38418a6c..00000000000 --- a/ux.symfony.com/templates/components/Table/TableHead.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Table/TableHeader.html.twig b/ux.symfony.com/templates/components/Table/TableHeader.html.twig deleted file mode 100644 index e5ae84ab9dd..00000000000 --- a/ux.symfony.com/templates/components/Table/TableHeader.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: '[&_tr]:border-b', - variants: {}, - compoundVariants: [] -) -%} - -
    - {% block content %}{% endblock %} -
    diff --git a/ux.symfony.com/templates/components/Table/TableRow.html.twig b/ux.symfony.com/templates/components/Table/TableRow.html.twig deleted file mode 100644 index d3de2112b82..00000000000 --- a/ux.symfony.com/templates/components/Table/TableRow.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{%- props -%} -{%- set style = html_cva( - base: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', - variants: {}, - compoundVariants: [] -) -%} - - - {% block content %}{% endblock %} - diff --git a/ux.symfony.com/templates/components/Toolkit/ComponentDoc.html.twig b/ux.symfony.com/templates/components/Toolkit/ComponentDoc.html.twig new file mode 100644 index 00000000000..7d75e97ac2c --- /dev/null +++ b/ux.symfony.com/templates/components/Toolkit/ComponentDoc.html.twig @@ -0,0 +1,3 @@ +
    + {{ this.content|markdown_to_html }} +
    diff --git a/ux.symfony.com/templates/toolkit/_code_block_right.html.twig b/ux.symfony.com/templates/toolkit/_code_block_right.html.twig new file mode 100644 index 00000000000..65b46fe9e83 --- /dev/null +++ b/ux.symfony.com/templates/toolkit/_code_block_right.html.twig @@ -0,0 +1,17 @@ +{# Freshly installed components are ready to use! #} + + + Symfony is cool + + Symfony is a set of reusable PHP components... + + + + ... and a PHP framework for web projects + + + + Visit symfony.com + + + diff --git a/ux.symfony.com/templates/toolkit/_kit_aside.html.twig b/ux.symfony.com/templates/toolkit/_kit_aside.html.twig new file mode 100644 index 00000000000..31eecd02e45 --- /dev/null +++ b/ux.symfony.com/templates/toolkit/_kit_aside.html.twig @@ -0,0 +1,23 @@ + diff --git a/ux.symfony.com/templates/toolkit/component.html.twig b/ux.symfony.com/templates/toolkit/component.html.twig new file mode 100644 index 00000000000..1d433d412f6 --- /dev/null +++ b/ux.symfony.com/templates/toolkit/component.html.twig @@ -0,0 +1,39 @@ +{% extends 'base.html.twig' %} + +{% set meta = { + title: 'Component ' ~ component.name ~ ' - ' ~ kit.humanName ~ ' Kit', + description: kit.descriptionForComponent(component.name), + canonical: url('app_icons'), + social: { + title: 'Component ' ~ component.name ~ ' - ' ~ kit.humanName ~ ' Kit', + description: kit.descriptionForComponent(component.name), + image: { + url: absolute_url(asset(package.getSocialImage('1200x675'))), + type: 'image/png', + width: 1200, + height: 675, + alt: package.humanName ~ ' - Kit ' ~ kit.humanName, + }, + } +} %} + +{% block content %} +
    + +
    + {{ include('toolkit/_kit_aside.html.twig') }} + +
    + + + +
    +
    +{% endblock %} diff --git a/ux.symfony.com/templates/toolkit/kit.html.twig b/ux.symfony.com/templates/toolkit/kit.html.twig new file mode 100644 index 00000000000..18e6ede5acc --- /dev/null +++ b/ux.symfony.com/templates/toolkit/kit.html.twig @@ -0,0 +1,40 @@ +{% extends 'base.html.twig' %} + +{% set meta = { + title: 'UX Toolkit - Kits - ' ~ kit.humanName, + description: kit.description, + canonical: url('app_icons'), + social: { + title: 'UX Toolkit - Kits - ' ~ kit.humanName, + description: kit.description, + image: { + url: absolute_url(asset(package.getSocialImage('1200x675'))), + type: 'image/png', + width: 1200, + height: 675, + alt: package.humanName ~ ' - Kit ' ~ kit.humanName, + }, + } +} %} + +{% block content %} +
    + +
    + {{ include('toolkit/_kit_aside.html.twig') }} + +
    + + +
    + {{- kit.gettingStarted|markdown_to_html -}} +
    +
    +
    +{% endblock %} diff --git a/ux.symfony.com/templates/ux_packages/_package_install.html.twig b/ux.symfony.com/templates/ux_packages/_package_install.html.twig index 4d3115f58ce..6d8261e7c30 100644 --- a/ux.symfony.com/templates/ux_packages/_package_install.html.twig +++ b/ux.symfony.com/templates/ux_packages/_package_install.html.twig @@ -4,7 +4,7 @@
    {% component Terminal with {bottomPadding: 20} %} {% block content %} - composer require {% if installOnlyInDevMode|default(false) == true%} --dev {% endif %}{{ package.composerName }} + {{ package.composerRequireCommand }} {% endblock %} {% endcomponent %}
    diff --git a/ux.symfony.com/templates/ux_packages/toolkit.html.twig b/ux.symfony.com/templates/ux_packages/toolkit.html.twig index 0dd51a8f144..c2529b75425 100644 --- a/ux.symfony.com/templates/ux_packages/toolkit.html.twig +++ b/ux.symfony.com/templates/ux_packages/toolkit.html.twig @@ -1,185 +1,103 @@ {% extends 'ux_packages/package.html.twig' %} {% block banner %} - {{ include('_banner.html.twig', {color_back: '#4a5d20'}) }} + {{ include('_banner.html.twig', {color_back: '#4d61d6'}) }} {% endblock %} {% block package_header %} {% component PackageHeader with { package: 'toolkit', - eyebrowText: 'Toolkit for your pages and templates', - packageBrowseText: 'Components', - packageBrowseUrl: path('app_toolkit_components'), + eyebrowText: 'Quickly build your Design System', } %} {% block title_header %} Build your Design System. {% endblock %} {% block sub_content %} - The UX Toolkit is a collection of components and templates that you can use to build your pages. + With Toolkit comes “Kits”, a set of ready-to-use and fully-customizable UI Twig components and more, to help you to build your Design System. {% endblock %} {% endcomponent %} {% endblock %} +{% block code_block_left %} + +# Install the components you need... +php bin/console ux:toolkit:install-component Card +php bin/console ux:toolkit:install-component Button + +# ... and find them in your templates/components folder! +tree templates/components +> templates/components +> ├── Button.html.twig +> ├── Card +> │   ├── Content.html.twig +> │   ├── Description.html.twig +> │   ├── Footer.html.twig +> │   ├── Header.html.twig +> │   └── Title.html.twig +> └── Card.html.twig + +{% endblock %} + +{% block code_block_right %} + +{% endblock %} {% block demo_title %}UX Toolkit{% endblock %} {% block demo_content %} - -
    -
    - -
    -
    - {% block card_example %} - - - Symfony is cool - Symfony is a set of reusable PHP components... - - - ... and a PHP framework for web projects - - - Visit symfony.com - - - {% endblock %} -
    -
    - -
    -
    - -
    -
    - {% block avatar_example %} - - - SF - - - - SF - - {% endblock %} -
    -
    + {% endblock %} - {% block package_install %} - {% set installOnlyInDevMode = true %} - {{ parent() }} -
    -

    - Then install your components with the following command: -

    - - {% component Terminal with {bottomPadding: 20} %} - {% block content %} - php bin/console ux:toolkit:install badge - php bin/console ux:toolkit:install button - php bin/console ux:toolkit:install card - php bin/console ux:toolkit:install ... - {% endblock %} - {% endcomponent %} - -

    - Official components require tailwindcss to be installed in your project. Visit the Tailwind CSS documentation for more information. - Components are based on shadcdn/ui design system. +

    +

    Kits

    +

    + Kits are a set of ready-to-use and fully-customizable UI Twig components and more, to help you to build your Design System.
    + We provide a few official kits, but you can also create your own and share it with the community!

    -

    - If you use component library using html_cva() or tailwind_merge() functions, remember to install them with: -

    - - {% component Terminal with {bottomPadding: 20} %} - {% block content %} - composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra - {% endblock %} - {% endcomponent %} - - -
    -
    -

    Philosophy

    -
    -
    -

    - Toolkit provides ready-to-use components, but freely customizable. -

    -

    - Official components are copied into your project in the templates/components folder, like classic TwigComponent. - Feel free to modify the code to suit your needs. -

    -
    -
    -

    - It is quite possible to install other component libraries, or even redistribute your own components - (for example, if you want to provide your internal Design System). -

    -

    - The choice of the component library is made in the configuration of your project: -

    - - - -

    - The theme key allows you to choose the component library to use, and can be any GitHub repository. -

    -
    -
    -
    -

    - If you want to distribute your own theme, please - classify your repository with the ux-toolkit topic to be referenced easily. -

    -

    - Your repository will be automatically detected by the GitHub search engine. +

    + {% for kit in kits %} +
    + +
    +

    + {{ kit.humanName }} +

    + +
    +

    {{ kit.description }}

    +
    +
    +
    + {% endfor %} + +
    + +
    +

    + + Community Kits + +

    + +
    +

    + Crafted by the community, for the community.
    + You can find them on GitHub with the ux-toolkit topic. +

    +
    +
    -
    -{% endblock %} - - -{% block javascripts %} -{{ parent() }} - -{% endblock %} - -{% block stylesheets %} -{# some tailwind classes are overrided by current theme #} - + {% endblock %} diff --git a/ux.symfony.com/templates/ux_packages/toolkit/components.html.twig b/ux.symfony.com/templates/ux_packages/toolkit/components.html.twig deleted file mode 100644 index 00118d772e8..00000000000 --- a/ux.symfony.com/templates/ux_packages/toolkit/components.html.twig +++ /dev/null @@ -1,107 +0,0 @@ -{% extends 'base.html.twig' %} - -{% block javascripts %} - {{ parent() }} - -{% endblock %} - -{% set meta = { - title: 'UX Toolkit - Build your Design System.', - description: 'Collection of components and templates that you can use to build your pages.', - canonical: url('app_toolkit_components'), - social: { - title: 'UX Toolkit - Build your Design System.', - description: 'Collection of components and templates that you can use to build your pages.', - image: { - url: absolute_url(asset(package.getSocialImage('1200x675'))), - type: 'image/png', - width: 1200, - height: 675, - alt: package.humanName ~ ' - Component Toolkit', - }, - } -} %} - -{% block header %} - {{ include('_header.html.twig', { - theme: 'white' - }) }} -{% endblock %} - - -{% block content %} - - {% component PackageHeader with { - package: package.name, - eyebrowText: "Build your Design System.", - installOnlyInDevMode: true - } %} - {% block title_header %} - Build your Design System. - {% endblock %} - - {% block sub_content %} - Collection of components and templates that you can use to build your pages. - {% endblock %} - {% endcomponent %} - -
    -
    -
    -

    Components

    -
    - {% for component in components %} - {% if component.type.value == 'component' and component.parentName is null %} - - {{ component.name }} - - {% endif %} - {% endfor %} -
    -
    - - -
    -

    {{ currentComponent.name }}

    -
    -

    Installation

    -

    - Run this command once to generate the templates/components/{{ currentComponent.name|lower }}.html.twig file in your project. -

    - {% component Terminal with {bottomPadding: 20} %} - {% block content %} - php bin/console ux:toolkit:install {{ currentComponent.name }} - {% endblock %} - {% endcomponent %} -
    - - {% if examples is not empty %} -
    -

    Examples

    -

    - Now you can directly use the following examples in your twig templates. -

    - - {% for example in examples %} -
    -
    {{ example.name }}
    - - -
    Your code:
    -
    - -
    -
    - {% endfor %} - -
    - {% endif %} -
    -
    -
    - - -{% endblock %} \ No newline at end of file diff --git a/ux.symfony.com/templates/ux_packages/toolkit/preview.html.twig b/ux.symfony.com/templates/ux_packages/toolkit/preview.html.twig deleted file mode 100644 index 3c839175e0d..00000000000 --- a/ux.symfony.com/templates/ux_packages/toolkit/preview.html.twig +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'ux_toolkit_preview.html.twig' %} - -{% block body %} - {{ html | raw }} -{% endblock %} diff --git a/ux.symfony.com/templates/ux_toolkit_preview.html.twig b/ux.symfony.com/templates/ux_toolkit_preview.html.twig deleted file mode 100644 index cf56fd0f6fb..00000000000 --- a/ux.symfony.com/templates/ux_toolkit_preview.html.twig +++ /dev/null @@ -1,91 +0,0 @@ -{% extends 'base.html.twig' %} - -{% block content %} - {% block body %}{% endblock %} -{% endblock %} - -{% block javascripts %} - {% if cssFramework|default('tailwindcss') == 'tailwindcss' %} - - {% endif %} -{% endblock %} - -{% block stylesheets %} - {% if cssFramework|default('tailwindcss') == 'tailwindcss' %} - - {% endif %} -{% endblock %} - -{% block aside %} -{% endblock %} - -{% block meta %} -{% endblock %} - -{% block banner %} -{% endblock %} From 284144d876cd0020696283c35afb9e4874700338 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 15 Apr 2025 23:06:46 +0200 Subject: [PATCH 33/58] [Toolkit] Remove tales-from-a-dev/twig-tailwind-extra dependency, to allow PHP 8.1 minimal support --- src/Toolkit/composer.json | 3 +-- src/Toolkit/tests/Fixtures/Kernel.php | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json index cc67ab0d33d..e767161609d 100644 --- a/src/Toolkit/composer.json +++ b/src/Toolkit/composer.json @@ -28,7 +28,7 @@ } ], "require": { - "php": ">=8.2", + "php": ">=8.1", "twig/twig": "^3.0", "symfony/console": "^6.4|^7.0", "symfony/filesystem": "^6.4|^7.0", @@ -39,7 +39,6 @@ }, "require-dev": { "symfony/finder": "6.4|^7.0", - "tales-from-a-dev/twig-tailwind-extra": "^0.3.0", "twig/extra-bundle": "^3.19|^4.0", "twig/html-extra": "^3.19", "zenstruck/console-test": "^1.7", diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php index 291c58b5c3e..c289a95cc66 100644 --- a/src/Toolkit/tests/Fixtures/Kernel.php +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -18,7 +18,6 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\UX\Toolkit\UXToolkitBundle; use Symfony\UX\TwigComponent\TwigComponentBundle; -use TalesFromADev\Twig\Extra\Tailwind\Bridge\Symfony\Bundle\TalesFromADevTwigExtraTailwindBundle; final class Kernel extends BaseKernel { @@ -31,7 +30,6 @@ public function registerBundles(): iterable new TwigBundle(), new TwigComponentBundle(), new UXToolkitBundle(), - new TalesFromADevTwigExtraTailwindBundle(), ]; } From a5f8a57238975835b064e801830221bbb1b159e0 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 17 Apr 2025 00:46:12 +0200 Subject: [PATCH 34/58] [Toolkit] Refactor `ToolkitKit` enum to `ToolkitKitId`, leverage description/uxIcon/installation steps in `Kit` VO --- src/Toolkit/kits/shadcn/INSTALL.md | 102 +++++++++++ src/Toolkit/kits/shadcn/manifest.json | 6 +- src/Toolkit/src/Assert.php | 2 +- src/Toolkit/src/Kit/Kit.php | 3 + src/Toolkit/src/Kit/KitFactory.php | 32 +++- src/Toolkit/tests/AssertTest.php | 6 +- .../tests/Command/LintKitCommandTest.php | 2 +- src/Toolkit/tests/Kit/KitFactoryTest.php | 1 - .../tests/Registry/GitHubRegistryTest.php | 2 +- .../tests/Registry/LocalRegistryTest.php | 2 +- .../src/Controller/SitemapController.php | 10 +- .../Toolkit/ComponentsController.php | 20 ++- .../src/Controller/Toolkit/KitsController.php | 18 +- .../UxPackage/ToolkitController.php | 14 +- ux.symfony.com/src/Enum/ToolkitKit.php | 162 ------------------ ux.symfony.com/src/Enum/ToolkitKitId.php | 24 +++ .../CodeBlockRenderer/CodeBlockRenderer.php | 2 +- .../src/Service/Toolkit/ToolkitService.php | 29 ++-- .../Twig/Components/Toolkit/ComponentDoc.php | 8 +- .../templates/toolkit/_kit_aside.html.twig | 4 +- .../templates/toolkit/component.html.twig | 16 +- .../templates/toolkit/kit.html.twig | 10 +- .../templates/ux_packages/toolkit.html.twig | 12 +- 23 files changed, 236 insertions(+), 251 deletions(-) create mode 100644 src/Toolkit/kits/shadcn/INSTALL.md delete mode 100644 ux.symfony.com/src/Enum/ToolkitKit.php create mode 100644 ux.symfony.com/src/Enum/ToolkitKitId.php diff --git a/src/Toolkit/kits/shadcn/INSTALL.md b/src/Toolkit/kits/shadcn/INSTALL.md new file mode 100644 index 00000000000..8442bae9bcd --- /dev/null +++ b/src/Toolkit/kits/shadcn/INSTALL.md @@ -0,0 +1,102 @@ +# Getting started + +This kit provides ready-to-use and fully-customizable UI Twig components based on [Shadcn UI](https://ui.shadcn.com/) components's **design**. + +Please note that not every Shadcn UI component is available in this kit, but we are working on it! + +## Requirements + +This kit requires TailwindCSS to work: +- If you use Symfony AssetMapper, you can install TailwindCSS with the [TailwindBundle](https://symfony.com/bundles/TailwindBundle/current/index.html), +- If you use Webpack Encore, you can follow the [TailwindCSS installation guide for Symfony](https://tailwindcss.com/docs/installation/framework-guides/symfony) + +## Installation + +In your `assets/styles/app.css`, after the TailwindCSS imports, add the following code: + +```css +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.269 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); +} + +@layer base { + * { + border-color: var(--border); + outline-color: var(--ring); + } + + body { + background-color: var(--background); + color: var(--foreground); + } +} +``` + +And voilà! You are now ready to use Shadcn components in your Symfony project. diff --git a/src/Toolkit/kits/shadcn/manifest.json b/src/Toolkit/kits/shadcn/manifest.json index 5a5f5d7d83c..6a39b488b8d 100644 --- a/src/Toolkit/kits/shadcn/manifest.json +++ b/src/Toolkit/kits/shadcn/manifest.json @@ -1,6 +1,8 @@ { - "name": "Shadcn", + "name": "Shadcn UI", + "description": "Component based on the Shadcn UI library, one of the most popular design systems in JavaScript world.", "license": "MIT", "homepage": "https://ux.symfony.com/components", - "authors": ["Shadcn", "Symfony Community"] + "authors": ["Shadcn", "Symfony Community"], + "ux-icon": "simple-icons:shadcnui" } diff --git a/src/Toolkit/src/Assert.php b/src/Toolkit/src/Assert.php index 7ae5d101061..b841ed3242c 100644 --- a/src/Toolkit/src/Assert.php +++ b/src/Toolkit/src/Assert.php @@ -22,7 +22,7 @@ */ public static function kitName(string $name): void { - if (1 !== preg_match('/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/', $name)) { + if (1 !== preg_match('/^[a-zA-Z0-9](?:[a-zA-Z0-9-_ ]{0,61}[a-zA-Z0-9])?$/', $name)) { throw new \InvalidArgumentException(\sprintf('Invalid kit name "%s".', $name)); } } diff --git a/src/Toolkit/src/Kit/Kit.php b/src/Toolkit/src/Kit/Kit.php index 9ab821c6117..5c668e1100d 100644 --- a/src/Toolkit/src/Kit/Kit.php +++ b/src/Toolkit/src/Kit/Kit.php @@ -36,6 +36,9 @@ public function __construct( public readonly string $homepage, public readonly array $authors, public readonly string $license, + public readonly ?string $description = null, + public readonly ?string $uxIcon = null, + public ?string $installAsMarkdown = null, private array $components = [], ) { Assert::kitName($this->name); diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php index 9e77d18e399..d8190e4a455 100644 --- a/src/Toolkit/src/Kit/KitFactory.php +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -54,11 +54,13 @@ public function createKitFromAbsolutePath(string $absolutePath): Kit $manifest = json_decode($this->filesystem->readFile($manifestPath), true, flags: \JSON_THROW_ON_ERROR); $kit = new Kit( - $absolutePath, - $manifest['name'] ?? throw new \InvalidArgumentException('Manifest file is missing "name" key.'), - $manifest['homepage'] ?? throw new \InvalidArgumentException('Manifest file is missing "homepage" key.'), - $manifest['authors'] ?? throw new \InvalidArgumentException('Manifest file is missing "authors" key.'), - $manifest['license'] ?? throw new \InvalidArgumentException('Manifest file is missing "license" key.'), + path: $absolutePath, + name: $manifest['name'] ?? throw new \InvalidArgumentException('Manifest file is missing "name" key.'), + homepage: $manifest['homepage'] ?? throw new \InvalidArgumentException('Manifest file is missing "homepage" key.'), + authors: $manifest['authors'] ?? throw new \InvalidArgumentException('Manifest file is missing "authors" key.'), + license: $manifest['license'] ?? throw new \InvalidArgumentException('Manifest file is missing "license" key.'), + description: $manifest['description'] ?? null, + uxIcon: $manifest['ux-icon'] ?? null, ); $this->synchronizeKit($kit); @@ -69,6 +71,7 @@ public function createKitFromAbsolutePath(string $absolutePath): Kit private function synchronizeKit(Kit $kit): void { $this->synchronizeKitComponents($kit); + $this->synchronizeKitDocumentation($kit); } private function synchronizeKitComponents(Kit $kit): void @@ -86,7 +89,6 @@ private function synchronizeKitComponents(Kit $kit): void $relativePathNameToKit = $file->getRelativePathname(); $relativePathName = str_replace($componentsPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); $componentName = $this->extractComponentName($relativePathName); - $docPath = Path::join($kit->path, 'docs', 'components', $componentName.'.md'); $component = new Component( name: $componentName, files: [new File( @@ -94,7 +96,6 @@ private function synchronizeKitComponents(Kit $kit): void relativePathNameToKit: $relativePathNameToKit, relativePathName: $relativePathName, )], - doc: $this->filesystem->exists($docPath) ? new Doc($this->filesystem->readFile($docPath)) : null, ); $kit->addComponent($component); @@ -107,4 +108,21 @@ private static function extractComponentName(string $pathnameRelativeToKit): str { return str_replace(['.html.twig', '/'], ['', ':'], $pathnameRelativeToKit); } + + private function synchronizeKitDocumentation(Kit $kit): void + { + // Read INSTALL.md if exists + $fileInstall = Path::join($kit->path, 'INSTALL.md'); + if ($this->filesystem->exists($fileInstall)) { + $kit->installAsMarkdown = $this->filesystem->readFile($fileInstall); + } + + // Iterate over Component and find their documentation + foreach ($kit->getComponents() as $component) { + $docPath = Path::join($kit->path, 'docs', 'components', $component->name.'.md'); + if ($this->filesystem->exists($docPath)) { + $component->doc = new Doc($this->filesystem->readFile($docPath)); + } + } + } } diff --git a/src/Toolkit/tests/AssertTest.php b/src/Toolkit/tests/AssertTest.php index 6d2fce96ddd..2c8cc114dd5 100644 --- a/src/Toolkit/tests/AssertTest.php +++ b/src/Toolkit/tests/AssertTest.php @@ -33,8 +33,8 @@ public static function provideValidKitNames(): \Generator yield ['1-my-kit']; yield ['my-kit-1']; yield ['my-kit-1-with-dashes']; - yield ['Shadcn-UI']; - yield ['Shadcn-UI-1']; + yield ['Shadcn UI']; + yield ['Shadcn UI-1']; // Single character yield ['a']; yield ['1']; @@ -47,6 +47,7 @@ public static function provideValidKitNames(): \Generator yield ['a-b-c']; yield ['a1-b2-c3']; yield ['A1-B2-C3']; + yield ['my_kit']; } /** @@ -71,7 +72,6 @@ public static function provideInvalidKitNames(): \Generator // Ending with hyphen yield ['my-kit-']; // Invalid characters - yield ['my_kit']; yield ['my.kit']; yield ['my@kit']; // Too long (64 chars) diff --git a/src/Toolkit/tests/Command/LintKitCommandTest.php b/src/Toolkit/tests/Command/LintKitCommandTest.php index 34053bdc50d..cdc94705b58 100644 --- a/src/Toolkit/tests/Command/LintKitCommandTest.php +++ b/src/Toolkit/tests/Command/LintKitCommandTest.php @@ -24,7 +24,7 @@ public function testShouldBeAbleToLint(): void $this->consoleCommand('ux:toolkit:lint-kit shadcn') ->execute() ->assertSuccessful() - ->assertOutputContains('The kit "Shadcn" is valid, it has 46 components') + ->assertOutputContains('The kit "Shadcn UI" is valid, it has 46 components') ; } } diff --git a/src/Toolkit/tests/Kit/KitFactoryTest.php b/src/Toolkit/tests/Kit/KitFactoryTest.php index 5efe7757a0f..b30269044ab 100644 --- a/src/Toolkit/tests/Kit/KitFactoryTest.php +++ b/src/Toolkit/tests/Kit/KitFactoryTest.php @@ -14,7 +14,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\UX\Toolkit\Dependency\ComponentDependency; use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; -use Symfony\UX\Toolkit\Dependency\Version; use Symfony\UX\Toolkit\Kit\KitFactory; final class KitFactoryTest extends KernelTestCase diff --git a/src/Toolkit/tests/Registry/GitHubRegistryTest.php b/src/Toolkit/tests/Registry/GitHubRegistryTest.php index 24471cf8f7a..f720e349523 100644 --- a/src/Toolkit/tests/Registry/GitHubRegistryTest.php +++ b/src/Toolkit/tests/Registry/GitHubRegistryTest.php @@ -64,7 +64,7 @@ public function testCanGetKitFromGithub(): void $kit = $githubRegistry->getKit('github.com/user/repo'); $this->assertTrue($isHttpClientCalled); - $this->assertSame('Shadcn', $kit->name); + $this->assertSame('Shadcn UI', $kit->name); $this->assertNotEmpty($kit->getComponents()); $this->assertFileExists($kit->path); $this->assertFileExists(Path::join($kit->path, 'templates/components/Button.html.twig')); diff --git a/src/Toolkit/tests/Registry/LocalRegistryTest.php b/src/Toolkit/tests/Registry/LocalRegistryTest.php index 96943d5ba3c..ca0bbd0fc76 100644 --- a/src/Toolkit/tests/Registry/LocalRegistryTest.php +++ b/src/Toolkit/tests/Registry/LocalRegistryTest.php @@ -28,6 +28,6 @@ public function testCanGetKit(): void $kit = $localRegistry->getKit('shadcn'); $this->assertInstanceOf(Kit::class, $kit); - $this->assertSame('Shadcn', $kit->name); + $this->assertSame('Shadcn UI', $kit->name); } } diff --git a/ux.symfony.com/src/Controller/SitemapController.php b/ux.symfony.com/src/Controller/SitemapController.php index c9c1371a0cf..05301ffc862 100644 --- a/ux.symfony.com/src/Controller/SitemapController.php +++ b/ux.symfony.com/src/Controller/SitemapController.php @@ -11,7 +11,6 @@ namespace App\Controller; -use App\Enum\ToolkitKit; use App\Service\LiveDemoRepository; use App\Service\Toolkit\ToolkitService; use App\Service\UxPackageRepository; @@ -67,12 +66,11 @@ private function getSitemapUrls(): iterable } // Toolkit kits - foreach ($this->toolkitService->getKits() as $kitName => $kit) { - yield $this->generateAbsoluteUrl('app_toolkit_kit', ['kit' => $kitName]); + foreach ($this->toolkitService->getKits() as $kitId => $kit) { + yield $this->generateAbsoluteUrl('app_toolkit_kit', ['kitId' => $kitId]); - $toolkitKit = ToolkitKit::from($kitName); - foreach ($this->toolkitService->getDocumentableComponents($toolkitKit) as $component) { - yield $this->generateAbsoluteUrl('app_toolkit_component', ['kit' => $kitName, 'componentName' => $component->name]); + foreach ($this->toolkitService->getDocumentableComponents($kit) as $component) { + yield $this->generateAbsoluteUrl('app_toolkit_component', ['kitId' => $kitId, 'componentName' => $component->name]); } } } diff --git a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php index 6445459d415..bb70a547771 100644 --- a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php +++ b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php @@ -11,7 +11,7 @@ namespace App\Controller\Toolkit; -use App\Enum\ToolkitKit; +use App\Enum\ToolkitKitId; use App\Service\Toolkit\ToolkitService; use App\Service\UxPackageRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -39,7 +39,7 @@ public function __construct( } #[Route('/toolkit/kits/{kit}/components/')] - public function listComponents(ToolkitKit $kit): Response + public function listComponents(ToolkitKitId $kit): Response { // TODO: implementing listing in the future :D @@ -48,10 +48,11 @@ public function listComponents(ToolkitKit $kit): Response ], Response::HTTP_FOUND); } - #[Route('/toolkit/kits/{kit}/components/{componentName}', name: 'app_toolkit_component')] - public function showComponent(ToolkitKit $kit, string $componentName): Response + #[Route('/toolkit/kits/{kitId}/components/{componentName}', name: 'app_toolkit_component')] + public function showComponent(ToolkitKitId $kitId, string $componentName): Response { - if (null === $component = $this->toolkitService->getComponent($kit, $componentName)) { + $kit = $this->toolkitService->getKit($kitId); + if (null === $component = $kit->getComponent($componentName)) { throw $this->createNotFoundException(\sprintf('Component "%s" not found', $componentName)); } @@ -59,8 +60,9 @@ public function showComponent(ToolkitKit $kit, string $componentName): Response return $this->render('toolkit/component.html.twig', [ 'package' => $package, - 'kit' => $kit, 'components' => $this->toolkitService->getDocumentableComponents($kit), + 'kit' => $kit, + 'kit_id' => $kitId, 'component' => $component, ]); } @@ -68,7 +70,7 @@ public function showComponent(ToolkitKit $kit, string $componentName): Response #[Route('/toolkit/component_preview', name: 'app_toolkit_component_preview')] public function previewComponent( Request $request, - #[MapQueryParameter] ToolkitKit $toolkitKit, + #[MapQueryParameter] ToolkitKitId $kitId, #[MapQueryParameter] string $code, #[MapQueryParameter] string $height, UriSigner $uriSigner, @@ -84,7 +86,7 @@ public function previewComponent( $profiler?->disable(); - $kit = $this->toolkitService->getKit($toolkitKit); + $kit = $this->toolkitService->getKit($kitId); $twig->setLoader(new ChainLoader([ new FilesystemLoader($kit->path.\DIRECTORY_SEPARATOR.'templates'.\DIRECTORY_SEPARATOR.'components'), @@ -120,7 +122,7 @@ public function findAnonymousComponentTemplate(string $name): ?string Preview - {{ importmap('toolkit-{$toolkitKit->value}') }} + {{ importmap('toolkit-{$kitId->value}') }} {$code} diff --git a/ux.symfony.com/src/Controller/Toolkit/KitsController.php b/ux.symfony.com/src/Controller/Toolkit/KitsController.php index 78d26179e2a..ea8f9ad3d72 100644 --- a/ux.symfony.com/src/Controller/Toolkit/KitsController.php +++ b/ux.symfony.com/src/Controller/Toolkit/KitsController.php @@ -11,7 +11,7 @@ namespace App\Controller\Toolkit; -use App\Enum\ToolkitKit; +use App\Enum\ToolkitKitId; use App\Service\Toolkit\ToolkitService; use App\Service\UxPackageRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -20,27 +20,23 @@ class KitsController extends AbstractController { - public function __construct( - private ToolkitService $toolkitService, - private UxPackageRepository $uxPackageRepository, - ) { - } - #[Route('/toolkit/kits')] public function listKits(): Response { return $this->redirectToRoute('app_toolkit', ['_fragment' => 'kits']); } - #[Route('/toolkit/kits/{kit}', name: 'app_toolkit_kit')] - public function showKit(ToolkitKit $kit): Response + #[Route('/toolkit/kits/{kitId}', name: 'app_toolkit_kit')] + public function showKit(ToolkitKitId $kitId, ToolkitService $toolkitService, UxPackageRepository $uxPackageRepository): Response { - $package = $this->uxPackageRepository->find('toolkit'); + $kit = $toolkitService->getKit($kitId); + $package = $uxPackageRepository->find('toolkit'); return $this->render('toolkit/kit.html.twig', [ 'package' => $package, 'kit' => $kit, - 'components' => $this->toolkitService->getDocumentableComponents($kit), + 'kit_id' => $kitId, + 'components' => $toolkitService->getDocumentableComponents($kit), ]); } } diff --git a/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php b/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php index 8a480999f49..2d75d231509 100644 --- a/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php +++ b/ux.symfony.com/src/Controller/UxPackage/ToolkitController.php @@ -11,7 +11,8 @@ namespace App\Controller\UxPackage; -use App\Enum\ToolkitKit; +use App\Enum\ToolkitKitId; +use App\Service\Toolkit\ToolkitService; use App\Service\UxPackageRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -22,12 +23,15 @@ class ToolkitController extends AbstractController { #[Route('/toolkit', name: 'app_toolkit')] - public function index(UxPackageRepository $packageRepository, UriSigner $uriSigner): Response - { + public function index( + UxPackageRepository $packageRepository, + UriSigner $uriSigner, + ToolkitService $toolkitService, + ): Response { $package = $packageRepository->find('toolkit'); $demoPreviewHeight = '400px'; $demoPreviewUrl = $uriSigner->sign($this->generateUrl('app_toolkit_component_preview', [ - 'toolkitKit' => ToolkitKit::Shadcn->value, + 'kitId' => ToolkitKitId::Shadcn->value, 'code' => <<<'TWIG' @@ -51,7 +55,7 @@ public function index(UxPackageRepository $packageRepository, UriSigner $uriSign return $this->render('ux_packages/toolkit.html.twig', [ 'package' => $package, - 'kits' => ToolkitKit::cases(), + 'kits' => $toolkitService->getKits(), 'demoPreviewUrl' => $demoPreviewUrl, 'demoPreviewHeight' => $demoPreviewHeight, ]); diff --git a/ux.symfony.com/src/Enum/ToolkitKit.php b/ux.symfony.com/src/Enum/ToolkitKit.php deleted file mode 100644 index 7a90a576d66..00000000000 --- a/ux.symfony.com/src/Enum/ToolkitKit.php +++ /dev/null @@ -1,162 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\Enum; - -/** - * For convenience and performance, official UX Toolkit kits are hardcoded. - * - * @internal - * - * @author Hugo Alliaume - */ -enum ToolkitKit: string -{ - case Shadcn = 'shadcn'; - - public function getHumanName(): string - { - return match ($this) { - self::Shadcn => 'Shadcn UI', - }; - } - - public function getDescription(): string - { - return match ($this) { - self::Shadcn => 'Component based on the Shadcn UI library, one of the most popular design systems in JavaScript world.', - }; - } - - public function getDescriptionForComponent(string $component): string - { - return match ($this) { - self::Shadcn => 'Component "'.$component.'" based on the Shadcn UI library, one of the most popular design systems in JavaScript world.', - }; - } - - public function getIcon(): string - { - return match ($this) { - self::Shadcn => 'simple-icons:shadcnui', - }; - } - - public function getGettingStarted(): string - { - return match ($this) { - self::Shadcn => << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Enum; + +/** + * For convenience and performance, official UX Toolkit kits are hardcoded. + * + * @internal + * + * @author Hugo Alliaume + */ +enum ToolkitKitId: string +{ + case Shadcn = 'shadcn'; +} diff --git a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php index 27f2354cc72..43174362f7b 100644 --- a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -48,7 +48,7 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer): \ if ($preview && $kit) { $previewUrl = $this->uriSigner->sign($this->urlGenerator->generate('app_toolkit_component_preview', [ - 'toolkitKit' => $kit, + 'kitId' => $kit, 'code' => $code, 'height' => $height, ], UrlGeneratorInterface::ABSOLUTE_URL)); diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php index 5fc07aa42db..52c0ccbc1e4 100644 --- a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -11,7 +11,7 @@ namespace App\Service\Toolkit; -use App\Enum\ToolkitKit; +use App\Enum\ToolkitKitId; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\UX\Toolkit\Component\Component; use Symfony\UX\Toolkit\Kit\Kit; @@ -25,26 +25,13 @@ public function __construct( ) { } - /** - * @return Component[] - */ - public function getDocumentableComponents(ToolkitKit $kit): array - { - return array_filter($this->getKit($kit)->getComponents(), fn (Component $component) => $component->doc); - } - - public function getComponent(ToolkitKit $kit, string $component): ?Component - { - return $this->getKit($kit)->getComponent($component); - } - - public function getKit(ToolkitKit $kit): Kit + public function getKit(ToolkitKitId $kit): Kit { return $this->getKits()[$kit->value] ?? throw new \InvalidArgumentException(\sprintf('Kit "%s" not found', $kit->value)); } /** - * @return array + * @return array */ public function getKits(): array { @@ -52,11 +39,19 @@ public function getKits(): array if (null === $kits) { $kits = []; - foreach (ToolkitKit::cases() as $kit) { + foreach (ToolkitKitId::cases() as $kit) { $kits[$kit->value] = $this->registryFactory->getForKit($kit->value)->getKit($kit->value); } } return $kits; } + + /** + * @return Component[] + */ + public function getDocumentableComponents(Kit $kit): array + { + return array_filter($kit->getComponents(), fn (Component $component) => $component->doc); + } } diff --git a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php index d97706fae64..92eef065e6a 100644 --- a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -11,7 +11,7 @@ namespace App\Twig\Components\Toolkit; -use App\Enum\ToolkitKit; +use App\Enum\ToolkitKitId; use App\Util\SourceCleaner; use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -25,7 +25,7 @@ #[AsTwigComponent] class ComponentDoc { - public ToolkitKit $kit; + public ToolkitKitId $kitId; public Component $component; public function __construct( @@ -57,7 +57,7 @@ private function insertInstallation(AbstractString $markdownContent): AbstractSt $installationCode = SourceCleaner::processTerminalLines(<<component->name} # or if you already use another kit -symfony console ux:toolkit:install-component {$this->component->name} --kit {$this->kit->value} +symfony console ux:toolkit:install-component {$this->component->name} --kit {$this->kitId->value} SHELL ); @@ -93,7 +93,7 @@ private function adaptPreviewableCodeBlocks(AbstractString $markdownContent): Ab $options = json_decode($matches['options'], true, flags: \JSON_THROW_ON_ERROR); if ($options['preview'] ?? false) { - $options['kit'] = $this->kit->value; + $options['kit'] = $this->kitId->value; } return \sprintf('```%s %s'."\n", $lang, json_encode($options, \JSON_THROW_ON_ERROR)); diff --git a/ux.symfony.com/templates/toolkit/_kit_aside.html.twig b/ux.symfony.com/templates/toolkit/_kit_aside.html.twig index 31eecd02e45..354e88765a6 100644 --- a/ux.symfony.com/templates/toolkit/_kit_aside.html.twig +++ b/ux.symfony.com/templates/toolkit/_kit_aside.html.twig @@ -5,7 +5,7 @@ @@ -13,7 +13,7 @@
      {% for component in components %}
    • - + {{ component.name }}
    • diff --git a/ux.symfony.com/templates/toolkit/component.html.twig b/ux.symfony.com/templates/toolkit/component.html.twig index 1d433d412f6..9eb4614a547 100644 --- a/ux.symfony.com/templates/toolkit/component.html.twig +++ b/ux.symfony.com/templates/toolkit/component.html.twig @@ -1,18 +1,20 @@ {% extends 'base.html.twig' %} +{% set title = 'Component ' ~ component.name ~ ' - ' ~ kit.name ~ ' Kit' %} +{% set description = "Documentation of component #{component.name}, from the UX Toolkit #{kit.name}, #{kit.description}." %} {% set meta = { - title: 'Component ' ~ component.name ~ ' - ' ~ kit.humanName ~ ' Kit', - description: kit.descriptionForComponent(component.name), + title, + description, canonical: url('app_icons'), social: { - title: 'Component ' ~ component.name ~ ' - ' ~ kit.humanName ~ ' Kit', - description: kit.descriptionForComponent(component.name), + title, + description, image: { url: absolute_url(asset(package.getSocialImage('1200x675'))), type: 'image/png', width: 1200, height: 675, - alt: package.humanName ~ ' - Kit ' ~ kit.humanName, + alt: package.humanName ~ ' - Kit ' ~ kit.name, }, } } %} @@ -28,12 +30,12 @@ - + {% endblock %} diff --git a/ux.symfony.com/templates/toolkit/kit.html.twig b/ux.symfony.com/templates/toolkit/kit.html.twig index 18e6ede5acc..43c6e20198f 100644 --- a/ux.symfony.com/templates/toolkit/kit.html.twig +++ b/ux.symfony.com/templates/toolkit/kit.html.twig @@ -1,18 +1,18 @@ {% extends 'base.html.twig' %} {% set meta = { - title: 'UX Toolkit - Kits - ' ~ kit.humanName, + title: 'UX Toolkit - Kits - ' ~ kit.name, description: kit.description, canonical: url('app_icons'), social: { - title: 'UX Toolkit - Kits - ' ~ kit.humanName, + title: 'UX Toolkit - Kits - ' ~ kit.name, description: kit.description, image: { url: absolute_url(asset(package.getSocialImage('1200x675'))), type: 'image/png', width: 1200, height: 675, - alt: package.humanName ~ ' - Kit ' ~ kit.humanName, + alt: package.humanName ~ ' - Kit ' ~ kit.name, }, } } %} @@ -28,12 +28,12 @@
      - {{- kit.gettingStarted|markdown_to_html -}} + {{- kit.installAsMarkdown|markdown_to_html -}}
      diff --git a/ux.symfony.com/templates/ux_packages/toolkit.html.twig b/ux.symfony.com/templates/ux_packages/toolkit.html.twig index c2529b75425..5d06a5b8d02 100644 --- a/ux.symfony.com/templates/ux_packages/toolkit.html.twig +++ b/ux.symfony.com/templates/ux_packages/toolkit.html.twig @@ -62,14 +62,16 @@ tree templates/components

      - {% for kit in kits %} + {% for kit_id, kit in kits %}
      - + {% if kit.uxIcon %} + + {% endif %}

      - {{ kit.humanName }} + {{ kit.name }}

      From 0b9bc266f15744ca2898d4ae19c90de119a71125 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 1 May 2025 18:05:48 +0200 Subject: [PATCH 35/58] [Toolkit] Add StimulusController support, extract logic to KitSynchronizer, rewrite installation system, create dedicated Installer/Pool system, make classes not readonly (PHP 8.1 comptability) --- .github/workflows/test.yaml | 8 - src/Toolkit/bin/ux-toolkit-kit-debug | 4 +- src/Toolkit/bin/ux-toolkit-kit-lint | 4 +- src/Toolkit/config/services.php | 33 +-- src/Toolkit/src/Assert.php | 11 +- .../src/{Component => Asset}/Component.php | 7 +- src/Toolkit/src/Asset/StimulusController.php | 38 ++++ .../src/Command/InstallComponentCommand.php | 72 ++----- src/Toolkit/src/Command/InstallKitCommand.php | 144 ------------- .../src/Component/ComponentInstaller.php | 47 ----- .../src/Dependency/ComponentDependency.php | 2 +- .../src/Dependency/DependenciesResolver.php | 92 -------- src/Toolkit/src/Dependency/Dependency.php | 3 +- .../src/Dependency/PhpPackageDependency.php | 6 +- .../StimulusControllerDependency.php | 38 ++++ src/Toolkit/src/Dependency/Version.php | 8 +- .../ComponentAlreadyExistsException.php | 26 --- src/Toolkit/src/File/Doc.php | 4 +- src/Toolkit/src/File/File.php | 8 +- src/Toolkit/src/File/FileType.php | 2 + .../src/Installer/InstallationReport.php | 37 ++++ src/Toolkit/src/Installer/Installer.php | 78 +++++++ src/Toolkit/src/Installer/Pool.php | 69 ++++++ src/Toolkit/src/Installer/PoolResolver.php | 67 ++++++ src/Toolkit/src/Kit/Kit.php | 52 ++++- src/Toolkit/src/Kit/KitFactory.php | 72 +------ src/Toolkit/src/Kit/KitSynchronizer.php | 198 ++++++++++++++++++ .../src/Registry/GitHubRegistryIdentity.php | 8 +- src/Toolkit/src/Registry/LocalRegistry.php | 8 +- src/Toolkit/src/Registry/RegistryFactory.php | 4 +- src/Toolkit/tests/AssertTest.php | 46 +++- .../tests/{Kit => Asset}/ComponentTest.php | 4 +- .../tests/Asset/StimulusControllerTest.php | 47 +++++ .../Command/InstallComponentCommandTest.php | 12 +- .../Dependency/ComponentDependencyTest.php | 2 +- .../Dependency/PhpPackageDependencyTest.php | 4 +- .../StimulusControllerDependencyTest.php | 34 +++ .../{Kit => }/Dependency/VersionTest.php | 2 +- src/Toolkit/tests/{Kit => File}/DocTest.php | 2 +- src/Toolkit/tests/{Kit => }/File/FileTest.php | 2 +- src/Toolkit/tests/Fixtures/Kernel.php | 4 +- .../manifest.json | 7 + .../templates/components/A.html.twig | 1 + .../templates/components/B.html.twig | 1 + .../templates/components/C.html.twig | 4 + .../controllers/clipboard_controller.js | 5 + .../controllers/date_picker_controller.js | 5 + .../controllers/local-time-controller.js | 5 + .../controllers/users/list_item_controller.js | 5 + .../with-stimulus-controllers/manifest.json | 7 + .../templates/components/Clipboard.html.twig | 1 + .../templates/components/DatePicker.html.twig | 1 + .../templates/components/LocalTime.html.twig | 1 + .../components/UsersListItem.html.twig | 1 + .../InstallerTest.php} | 59 +++--- .../tests/Installer/PoolResolverTest.php | 78 +++++++ src/Toolkit/tests/Installer/PoolTest.php | 80 +++++++ .../tests/Kit/DependenciesResolverTest.php | 65 ------ src/Toolkit/tests/Kit/KitFactoryTest.php | 36 +++- src/Toolkit/tests/Kit/KitSynchronizerTest.php | 73 +++++++ src/Toolkit/tests/Kit/KitTest.php | 2 +- .../tests/Registry/GitHubRegistryTest.php | 4 +- .../tests/Registry/LocalRegistryTest.php | 2 +- .../src/Service/Toolkit/ToolkitService.php | 2 +- .../Twig/Components/Toolkit/ComponentDoc.php | 2 +- 65 files changed, 1120 insertions(+), 636 deletions(-) rename src/Toolkit/src/{Component => Asset}/Component.php (87%) create mode 100644 src/Toolkit/src/Asset/StimulusController.php delete mode 100644 src/Toolkit/src/Command/InstallKitCommand.php delete mode 100644 src/Toolkit/src/Component/ComponentInstaller.php delete mode 100644 src/Toolkit/src/Dependency/DependenciesResolver.php create mode 100644 src/Toolkit/src/Dependency/StimulusControllerDependency.php delete mode 100644 src/Toolkit/src/Exception/ComponentAlreadyExistsException.php create mode 100644 src/Toolkit/src/Installer/InstallationReport.php create mode 100644 src/Toolkit/src/Installer/Installer.php create mode 100644 src/Toolkit/src/Installer/Pool.php create mode 100644 src/Toolkit/src/Installer/PoolResolver.php create mode 100644 src/Toolkit/src/Kit/KitSynchronizer.php rename src/Toolkit/tests/{Kit => Asset}/ComponentTest.php (98%) create mode 100644 src/Toolkit/tests/Asset/StimulusControllerTest.php rename src/Toolkit/tests/{Kit => }/Dependency/ComponentDependencyTest.php (94%) rename src/Toolkit/tests/{Kit => }/Dependency/PhpPackageDependencyTest.php (90%) create mode 100644 src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php rename src/Toolkit/tests/{Kit => }/Dependency/VersionTest.php (96%) rename src/Toolkit/tests/{Kit => File}/DocTest.php (94%) rename src/Toolkit/tests/{Kit => }/File/FileTest.php (98%) create mode 100644 src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json create mode 100644 src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig create mode 100644 src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig create mode 100644 src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig create mode 100644 src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig rename src/Toolkit/tests/{Kit/ComponentInstallerTest.php => Installer/InstallerTest.php} (63%) create mode 100644 src/Toolkit/tests/Installer/PoolResolverTest.php create mode 100644 src/Toolkit/tests/Installer/PoolTest.php delete mode 100644 src/Toolkit/tests/Kit/DependenciesResolverTest.php create mode 100644 src/Toolkit/tests/Kit/KitSynchronizerTest.php diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 596b291f7ae..a17aa41e180 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -78,20 +78,12 @@ jobs: dependency-version: 'highest' - php-version: '8.4' dependency-version: 'highest' - - php-version: '8.2' - minimum-stability: stable - component: Toolkit - - php-version: '8.2' - minimum-stability: dev - component: Toolkit component: ${{ fromJson(needs.tests-php-components.outputs.components )}} exclude: - php-version: '8.1' minimum-stability: 'dev' - php-version: '8.3' minimum-stability: 'dev' - - component: Toolkit # does not support PHP 8.1 - php-version: '8.1' - component: Swup # has no tests - component: Turbo # has its own workflow (test-turbo.yml) - component: Typed # has no tests diff --git a/src/Toolkit/bin/ux-toolkit-kit-debug b/src/Toolkit/bin/ux-toolkit-kit-debug index 9bdd89127c5..91270bfa469 100755 --- a/src/Toolkit/bin/ux-toolkit-kit-debug +++ b/src/Toolkit/bin/ux-toolkit-kit-debug @@ -20,7 +20,7 @@ use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\Service\ServiceLocatorTrait; use Symfony\Contracts\Service\ServiceProviderInterface; use Symfony\UX\Toolkit\Command\DebugKitCommand; -use Symfony\UX\Toolkit\Dependency\DependenciesResolver; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; use Symfony\UX\Toolkit\Kit\KitFactory; use Symfony\UX\Toolkit\Registry\GitHubRegistry; use Symfony\UX\Toolkit\Registry\LocalRegistry; @@ -46,7 +46,7 @@ if (!class_exists(Application::class)) { } $filesystem = new Filesystem(); -$kitFactory = new KitFactory($filesystem, new DependenciesResolver($filesystem)); +$kitFactory = new KitFactory($filesystem, new KitSynchronizer($filesystem)); $registryFactory = new RegistryFactory(new class([ Type::Local->value => fn () => new LocalRegistry($kitFactory, $filesystem, getcwd() ?? throw new \RuntimeException('The current working directory could not be determined.')), Type::GitHub->value => fn () => new GitHubRegistry($kitFactory, $filesystem, class_exists(HttpClient::class) ? HttpClient::create() : null), diff --git a/src/Toolkit/bin/ux-toolkit-kit-lint b/src/Toolkit/bin/ux-toolkit-kit-lint index fee8015a660..a7b5eb16c1e 100755 --- a/src/Toolkit/bin/ux-toolkit-kit-lint +++ b/src/Toolkit/bin/ux-toolkit-kit-lint @@ -20,7 +20,7 @@ use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\Service\ServiceLocatorTrait; use Symfony\Contracts\Service\ServiceProviderInterface; use Symfony\UX\Toolkit\Command\LintKitCommand; -use Symfony\UX\Toolkit\Dependency\DependenciesResolver; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; use Symfony\UX\Toolkit\Kit\KitFactory; use Symfony\UX\Toolkit\Registry\GitHubRegistry; use Symfony\UX\Toolkit\Registry\LocalRegistry; @@ -46,7 +46,7 @@ if (!class_exists(Application::class)) { } $filesystem = new Filesystem(); -$kitFactory = new KitFactory($filesystem, new DependenciesResolver($filesystem)); +$kitFactory = new KitFactory($filesystem, new KitSynchronizer($filesystem)); $registryFactory = new RegistryFactory(new class([ Type::Local->value => fn () => new LocalRegistry($kitFactory, $filesystem, getcwd() ?? throw new \RuntimeException('The current working directory could not be determined.')), Type::GitHub->value => fn () => new GitHubRegistry($kitFactory, $filesystem, class_exists(HttpClient::class) ? HttpClient::create() : null), diff --git a/src/Toolkit/config/services.php b/src/Toolkit/config/services.php index b2b3074e5ae..bc992d3a26c 100644 --- a/src/Toolkit/config/services.php +++ b/src/Toolkit/config/services.php @@ -13,11 +13,9 @@ use Symfony\UX\Toolkit\Command\DebugKitCommand; use Symfony\UX\Toolkit\Command\InstallComponentCommand; -use Symfony\UX\Toolkit\Command\InstallKitCommand; use Symfony\UX\Toolkit\Command\LintKitCommand; -use Symfony\UX\Toolkit\Component\ComponentInstaller; -use Symfony\UX\Toolkit\Dependency\DependenciesResolver; use Symfony\UX\Toolkit\Kit\KitFactory; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; use Symfony\UX\Toolkit\Registry\GitHubRegistry; use Symfony\UX\Toolkit\Registry\LocalRegistry; use Symfony\UX\Toolkit\Registry\RegistryFactory; @@ -40,15 +38,7 @@ ->args([ param('ux_toolkit.kit'), service('.ux_toolkit.registry.factory'), - service('.ux_toolkit.component.component_installer'), - ]) - ->tag('console.command') - - ->set('.ux_toolkit.command.install_kit', InstallKitCommand::class) - ->args([ - param('ux_toolkit.kit'), - service('.ux_toolkit.registry.factory'), - service('.ux_toolkit.component.component_installer'), + service('filesystem'), ]) ->tag('console.command') @@ -84,30 +74,15 @@ // Kit - ->set('.ux_toolkit.kit.factory', KitFactory::class) - ->args([ - service('filesystem'), - service('.ux_toolkit.dependency.dependencies_resolver'), - ]) - ->set('.ux_toolkit.kit.kit_factory', KitFactory::class) ->args([ service('filesystem'), - service('.ux_toolkit.dependency.dependencies_resolver'), - ]) - - // Component - ->set('.ux_toolkit.component.component_installer', ComponentInstaller::class) - ->args([ - service('filesystem'), + service('.ux_toolkit.kit.kit_synchronizer'), ]) - // Dependency - - ->set('.ux_toolkit.dependency.dependencies_resolver', DependenciesResolver::class) + ->set('.ux_toolkit.kit.kit_synchronizer', KitSynchronizer::class) ->args([ service('filesystem'), ]) - ; }; diff --git a/src/Toolkit/src/Assert.php b/src/Toolkit/src/Assert.php index b841ed3242c..70dcb33cc4d 100644 --- a/src/Toolkit/src/Assert.php +++ b/src/Toolkit/src/Assert.php @@ -11,7 +11,7 @@ namespace Symfony\UX\Toolkit; -final readonly class Assert +final class Assert { /** * Assert that the kit name is valid (ex: "Shadcn", "Tailwind", "Bootstrap", etc.). @@ -52,7 +52,14 @@ public static function phpPackageName(string $name): void { // Taken from https://github.com/composer/composer/blob/main/res/composer-schema.json if (1 !== preg_match('/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$/', $name)) { - throw new \InvalidArgumentException(\sprintf('Invalid package name "%s".', $name)); + throw new \InvalidArgumentException(\sprintf('Invalid PHP package name "%s".', $name)); + } + } + + public static function stimulusControllerName(string $name): void + { + if (1 !== preg_match('/^[a-z][a-z0-9-]*[a-z0-9]$/', $name)) { + throw new \InvalidArgumentException(\sprintf('Invalid Stimulus controller name "%s".', $name)); } } } diff --git a/src/Toolkit/src/Component/Component.php b/src/Toolkit/src/Asset/Component.php similarity index 87% rename from src/Toolkit/src/Component/Component.php rename to src/Toolkit/src/Asset/Component.php index 6b14fec7547..0b0baf2b037 100644 --- a/src/Toolkit/src/Component/Component.php +++ b/src/Toolkit/src/Asset/Component.php @@ -9,12 +9,13 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Component; +namespace Symfony\UX\Toolkit\Asset; use Symfony\UX\Toolkit\Assert; use Symfony\UX\Toolkit\Dependency\ComponentDependency; use Symfony\UX\Toolkit\Dependency\Dependency; use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; use Symfony\UX\Toolkit\File\Doc; use Symfony\UX\Toolkit\File\File; @@ -58,6 +59,10 @@ public function addDependency(Dependency $dependency): void if ($existingDependency instanceof ComponentDependency && $existingDependency->name === $dependency->name) { return; } + + if ($existingDependency instanceof StimulusControllerDependency && $existingDependency->name === $dependency->name) { + return; + } } $this->dependencies[] = $dependency; diff --git a/src/Toolkit/src/Asset/StimulusController.php b/src/Toolkit/src/Asset/StimulusController.php new file mode 100644 index 00000000000..94db7e19a89 --- /dev/null +++ b/src/Toolkit/src/Asset/StimulusController.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Asset; + +use Symfony\UX\Toolkit\Assert; +use Symfony\UX\Toolkit\File\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +class StimulusController +{ + /** + * @param non-empty-string $name + * @param list $files + */ + public function __construct( + public readonly string $name, + public readonly array $files, + ) { + Assert::stimulusControllerName($this->name); + + if ([] === $files) { + throw new \InvalidArgumentException(\sprintf('Stimulus controller "%s" has no files.', $name)); + } + } +} diff --git a/src/Toolkit/src/Command/InstallComponentCommand.php b/src/Toolkit/src/Command/InstallComponentCommand.php index ba1cc1df523..11a2ef8e498 100644 --- a/src/Toolkit/src/Command/InstallComponentCommand.php +++ b/src/Toolkit/src/Command/InstallComponentCommand.php @@ -11,7 +11,6 @@ namespace Symfony\UX\Toolkit\Command; -use Composer\InstalledVersions; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -19,12 +18,11 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; -use Symfony\UX\Toolkit\Component\Component; -use Symfony\UX\Toolkit\Component\ComponentInstaller; -use Symfony\UX\Toolkit\Dependency\ComponentDependency; -use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; -use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Installer\Installer; use Symfony\UX\Toolkit\Kit\Kit; use Symfony\UX\Toolkit\Registry\RegistryFactory; @@ -46,7 +44,7 @@ class InstallComponentCommand extends Command public function __construct( private readonly string $kitName, private readonly RegistryFactory $registryFactory, - private readonly ComponentInstaller $componentInstaller, + private readonly Filesystem $filesystem, ) { parent::__construct(); } @@ -87,7 +85,6 @@ protected function configure(): void protected function initialize(InputInterface $input, OutputInterface $output): void { $this->io = new SymfonyStyle($input, $output); - $this->isInteractive = $input->isInteractive(); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -123,29 +120,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - // Install the component and dependencies - $destination = $input->getOption('destination'); + $this->io->text(\sprintf('Installing component %s...', $component->name)); - if (!$this->installComponent($kit, $component, $destination)) { - return Command::FAILURE; - } + $installer = new Installer($this->filesystem, fn (string $question) => $this->io->confirm($question, $input->isInteractive())); + $installationReport = $installer->installComponent($kit, $component, $destinationPath = $input->getOption('destination'), $input->getOption('force')); - // Iterate over the component's dependencies - $phpDependenciesToInstall = []; - foreach ($component->getDependencies() as $dependency) { - if ($dependency instanceof ComponentDependency) { - if (!$this->installComponent($kit, $kit->getComponent($dependency->name), $destination)) { - return Command::FAILURE; - } - } elseif ($dependency instanceof PhpPackageDependency && !InstalledVersions::isInstalled($dependency->name)) { - $phpDependenciesToInstall[] = $dependency; - } + if ([] === $installationReport->newFiles) { + $this->io->warning('The component has not been installed.'); + + return Command::SUCCESS; } - $this->io->success(\sprintf('The component "%s" has been installed.', $component->name)); + $this->io->success('The component has been installed.'); + $this->io->writeln('The following file(s) have been added to your project:'); + $this->io->listing(array_map(fn (File $file) => Path::join($destinationPath, $file->relativePathName), $installationReport->newFiles)); - if ([] !== $phpDependenciesToInstall) { - $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $phpDependenciesToInstall))); + if ([] !== $installationReport->suggestedPhpPackages) { + $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $installationReport->suggestedPhpPackages))); $this->io->newLine(); } @@ -168,33 +159,4 @@ private function getAlternativeComponents(Kit $kit, string $componentName): arra return $alternative; } - - private function installComponent(Kit $kit, Component $component, string $destination, bool $force = false): bool - { - try { - $this->io->text(\sprintf('Installing component "%s"...', $component->name)); - - $this->componentInstaller->install($kit, $component, $destination); - } catch (ComponentAlreadyExistsException) { - if ($force) { - $this->componentInstaller->install($kit, $component, $destination, true); - - return true; - } - - $this->io->warning(\sprintf('The component "%s" already exists.', $component->name)); - - if ($this->isInteractive) { - if ($this->io->confirm('Do you want to overwrite it?')) { - $this->componentInstaller->install($kit, $component, $destination, true); - - return true; - } - } else { - return false; - } - } - - return true; - } } diff --git a/src/Toolkit/src/Command/InstallKitCommand.php b/src/Toolkit/src/Command/InstallKitCommand.php deleted file mode 100644 index 9a59f81f200..00000000000 --- a/src/Toolkit/src/Command/InstallKitCommand.php +++ /dev/null @@ -1,144 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Command; - -use Composer\InstalledVersions; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\UX\Toolkit\Component\Component; -use Symfony\UX\Toolkit\Component\ComponentInstaller; -use Symfony\UX\Toolkit\Dependency\ComponentDependency; -use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; -use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; -use Symfony\UX\Toolkit\Kit\Kit; -use Symfony\UX\Toolkit\Registry\RegistryFactory; - -/** - * @author Jean-François Lépine - * - * @internal - */ -#[AsCommand( - name: 'ux:toolkit:install-kit', - description: 'This command will install a full UX Toolkit kit in your project', -)] -class InstallKitCommand extends Command -{ - private SymfonyStyle $io; - private bool $isInteractive; - - public function __construct( - private readonly string $kitName, - private readonly RegistryFactory $registryFactory, - private readonly ComponentInstaller $componentInstaller, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the kit installation, even if some files already exist') - ->addOption('kit', 't', InputOption::VALUE_OPTIONAL, 'Override the kit name', $this->kitName) - ->setHelp( - <<%command.name% command will install a full UX Toolkit kit in your project. - -To fully install your current kit, use: - -php %command.full_name% - -To fully install a kit from an official UX Toolkit kit, use the --kit option: - -php %command.full_name% --kit=shadcn - -To fully install a kit from an external GitHub kit, use the --kit option: - -php %command.full_name% --kit=github.com/user/repository@kit -php %command.full_name% --kit=github.com/user/repository@kit:branch -EOF - ); - } - - protected function initialize(InputInterface $input, OutputInterface $output): void - { - $this->io = new SymfonyStyle($input, $output); - $this->isInteractive = $input->isInteractive(); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $kitName = $input->getOption('kit'); - $registry = $this->registryFactory->getForKit($kitName); - $kit = $registry->getKit($kitName); - - foreach ($kit->getComponents() as $component) { - if (!$this->installComponent($kit, $component, 'templates/components')) { - return Command::FAILURE; - } - } - - // Iterate over the component's dependencies - $phpDependenciesToInstall = []; - foreach ($component->getDependencies() as $dependency) { - if ($dependency instanceof ComponentDependency) { - if (!$this->installComponent($kit, $kit->getComponent($dependency->name), 'templates/components')) { - return Command::FAILURE; - } - } elseif ($dependency instanceof PhpPackageDependency && !\array_key_exists($dependency->name, $phpDependenciesToInstall) && !InstalledVersions::isInstalled($dependency->name)) { - $phpDependenciesToInstall[$dependency->name] = $dependency; - } - } - - if ([] !== $phpDependenciesToInstall) { - $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $phpDependenciesToInstall))); - $this->io->newLine(); - } - - return Command::SUCCESS; - } - - private function installComponent(Kit $kit, Component $component, string $destination, bool $force = false): bool - { - try { - $this->io->text(\sprintf('Installing component "%s"...', $component->name)); - - $this->componentInstaller->install($kit, $component, $destination); - } catch (ComponentAlreadyExistsException) { - if ($force) { - $this->componentInstaller->install($kit, $component, $destination, true); - - return true; - } - - $this->io->warning(\sprintf('The component "%s" already exists.', $component->name)); - - if ($this->isInteractive) { - if ($this->io->confirm('Do you want to overwrite it?')) { - $this->componentInstaller->install($kit, $component, $destination, true); - - return true; - } - } else { - return false; - } - } - - return true; - } -} diff --git a/src/Toolkit/src/Component/ComponentInstaller.php b/src/Toolkit/src/Component/ComponentInstaller.php deleted file mode 100644 index 1b702bd1ce8..00000000000 --- a/src/Toolkit/src/Component/ComponentInstaller.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Component; - -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Filesystem\Path; -use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; -use Symfony\UX\Toolkit\Kit\Kit; - -/** - * @internal - * - * @author Hugo Alliaume - */ -final readonly class ComponentInstaller -{ - public function __construct( - private Filesystem $filesystem, - ) { - } - - /** - * @param non-empty-string $destination - */ - public function install(Kit $kit, Component $component, string $destination, bool $force = false): void - { - foreach ($component->files as $file) { - $componentPath = Path::join($kit->path, $file->relativePathNameToKit); - $componentDestinationPath = Path::join($destination, $file->relativePathName); - - if ($this->filesystem->exists($componentDestinationPath) && !$force) { - throw new ComponentAlreadyExistsException($component->name); - } - - $this->filesystem->copy($componentPath, $componentDestinationPath, $force); - } - } -} diff --git a/src/Toolkit/src/Dependency/ComponentDependency.php b/src/Toolkit/src/Dependency/ComponentDependency.php index 9e42df415f1..ae9e231c0f3 100644 --- a/src/Toolkit/src/Dependency/ComponentDependency.php +++ b/src/Toolkit/src/Dependency/ComponentDependency.php @@ -20,7 +20,7 @@ * * @author Hugo Alliaume */ -final readonly class ComponentDependency implements Dependency +final class ComponentDependency implements Dependency { /** * @param non-empty-string $name The name of the component, e.g. "Table" or "Table:Body" diff --git a/src/Toolkit/src/Dependency/DependenciesResolver.php b/src/Toolkit/src/Dependency/DependenciesResolver.php deleted file mode 100644 index f7a2fa65068..00000000000 --- a/src/Toolkit/src/Dependency/DependenciesResolver.php +++ /dev/null @@ -1,92 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Dependency; - -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Filesystem\Path; -use Symfony\UX\Toolkit\Component\Component; -use Symfony\UX\Toolkit\File\FileType; -use Symfony\UX\Toolkit\Kit\Kit; - -/** - * @internal - * - * @author Hugo Alliaume - */ -final readonly class DependenciesResolver -{ - /** - * @see https://regex101.com/r/WasRGf/1 - */ - private const RE_TWIG_COMPONENT_REFERENCES = '/[a-zA-Z0-9:_-]+)/'; - - public function __construct( - private Filesystem $filesystem, - ) { - } - - public function resolveDependencies(Kit $kit): void - { - foreach ($kit->getComponents() as $component) { - $this->resolveComponentDependencies($kit, $component); - } - } - - private function resolveComponentDependencies(Kit $kit, Component $component): void - { - // Find dependencies based on component name - foreach ($kit->getComponents() as $otherComponent) { - if ($component->name === $otherComponent->name) { - continue; - } - - // Find components with the component name as a prefix - if (str_starts_with($otherComponent->name, $component->name.':')) { - $component->addDependency(new ComponentDependency($otherComponent->name)); - } - } - - // Find dependencies based on file content - foreach ($component->files as $file) { - $fileContent = $this->filesystem->readFile(Path::join($kit->path, $file->relativePathNameToKit)); - - if (FileType::Twig === $file->type) { - if (str_contains($fileContent, 'html_cva')) { - $component->addDependency(new PhpPackageDependency('twig/extra-bundle')); - $component->addDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0))); - } - - if (str_contains($fileContent, 'tailwind_merge')) { - $component->addDependency(new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra')); - } - - if (str_contains($fileContent, 'name) { - continue; - } - - if ('ux:icon' === strtolower($componentReferenceName)) { - $component->addDependency(new PhpPackageDependency('symfony/ux-icons')); - } elseif ('ux:map' === strtolower($componentReferenceName)) { - $component->addDependency(new PhpPackageDependency('symfony/ux-map')); - } elseif (null === $componentReference = $kit->getComponent($componentReferenceName)) { - throw new \RuntimeException(\sprintf('Component "%s" not found in component "%s" (file "%s")', $componentReferenceName, $component->name, $file->relativePathNameToKit)); - } else { - $component->addDependency(new ComponentDependency($componentReference->name)); - } - } - } - } - } - } -} diff --git a/src/Toolkit/src/Dependency/Dependency.php b/src/Toolkit/src/Dependency/Dependency.php index 29f2d9d907b..4f6312fb39e 100644 --- a/src/Toolkit/src/Dependency/Dependency.php +++ b/src/Toolkit/src/Dependency/Dependency.php @@ -18,7 +18,6 @@ * * @author Hugo Alliaume */ -interface Dependency +interface Dependency extends \Stringable { - public function __toString(): string; } diff --git a/src/Toolkit/src/Dependency/PhpPackageDependency.php b/src/Toolkit/src/Dependency/PhpPackageDependency.php index b6d813e3746..c66286979fc 100644 --- a/src/Toolkit/src/Dependency/PhpPackageDependency.php +++ b/src/Toolkit/src/Dependency/PhpPackageDependency.php @@ -20,14 +20,14 @@ * * @author Hugo Alliaume */ -final readonly class PhpPackageDependency implements Dependency +final class PhpPackageDependency implements Dependency { /** * @param non-empty-string $name */ public function __construct( - public string $name, - public ?Version $constraintVersion = null, + public readonly string $name, + public readonly ?Version $constraintVersion = null, ) { Assert::phpPackageName($name); } diff --git a/src/Toolkit/src/Dependency/StimulusControllerDependency.php b/src/Toolkit/src/Dependency/StimulusControllerDependency.php new file mode 100644 index 00000000000..00b3c8db9b2 --- /dev/null +++ b/src/Toolkit/src/Dependency/StimulusControllerDependency.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +use Symfony\UX\Toolkit\Assert; + +/** + * Represents a dependency on a Stimulus controller. + * + * @internal + * + * @author Hugo Alliaume + */ +final class StimulusControllerDependency implements Dependency +{ + /** + * @param non-empty-string $name + */ + public function __construct( + public string $name, + ) { + Assert::stimulusControllerName($this->name); + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Toolkit/src/Dependency/Version.php b/src/Toolkit/src/Dependency/Version.php index 4bbd6417d68..ff94ebdb4fc 100644 --- a/src/Toolkit/src/Dependency/Version.php +++ b/src/Toolkit/src/Dependency/Version.php @@ -18,7 +18,7 @@ * * @author Hugo Alliaume */ -final readonly class Version +final class Version implements \Stringable { /** * @param int<0, max> $major @@ -26,9 +26,9 @@ * @param int<0, max> $patch */ public function __construct( - public int $major, - public int $minor, - public int $patch, + public readonly int $major, + public readonly int $minor, + public readonly int $patch, ) { } diff --git a/src/Toolkit/src/Exception/ComponentAlreadyExistsException.php b/src/Toolkit/src/Exception/ComponentAlreadyExistsException.php deleted file mode 100644 index accf55f47d4..00000000000 --- a/src/Toolkit/src/Exception/ComponentAlreadyExistsException.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Exception; - -/** - * @author Hugo Alliaume - * - * @internal - */ -final class ComponentAlreadyExistsException extends \RuntimeException -{ - public function __construct( - public readonly string $componentName, - ) { - parent::__construct(\sprintf('The component "%s" already exists.', $this->componentName)); - } -} diff --git a/src/Toolkit/src/File/Doc.php b/src/Toolkit/src/File/Doc.php index 8d2764366c0..1c66f4b619e 100644 --- a/src/Toolkit/src/File/Doc.php +++ b/src/Toolkit/src/File/Doc.php @@ -16,13 +16,13 @@ * * @author Hugo Alliaume */ -final readonly class Doc +final class Doc { /** * @param non-empty-string $markdownContent */ public function __construct( - public string $markdownContent, + public readonly string $markdownContent, ) { } } diff --git a/src/Toolkit/src/File/File.php b/src/Toolkit/src/File/File.php index edb89ead5a7..df43a7c7dae 100644 --- a/src/Toolkit/src/File/File.php +++ b/src/Toolkit/src/File/File.php @@ -18,7 +18,7 @@ * * @author Hugo Alliaume */ -final readonly class File +final class File implements \Stringable { /** * @param non-empty-string $relativePathNameToKit relative path from the kit root directory, example "templates/components/Table/Body.html.twig" @@ -27,9 +27,9 @@ * @throws \InvalidArgumentException */ public function __construct( - public FileType $type, - public string $relativePathNameToKit, - public string $relativePathName, + public readonly FileType $type, + public readonly string $relativePathNameToKit, + public readonly string $relativePathName, ) { if (!Path::isRelative($relativePathNameToKit)) { throw new \InvalidArgumentException(\sprintf('The path to the kit "%s" must be relative.', $relativePathNameToKit)); diff --git a/src/Toolkit/src/File/FileType.php b/src/Toolkit/src/File/FileType.php index fdebcc2565e..26506f3a868 100644 --- a/src/Toolkit/src/File/FileType.php +++ b/src/Toolkit/src/File/FileType.php @@ -19,11 +19,13 @@ enum FileType: string { case Twig = 'twig'; + case StimulusController = 'stimulus_controller'; public function getLabel(): string { return match ($this) { self::Twig => 'Twig', + self::StimulusController => 'Stimulus Controller', }; } } diff --git a/src/Toolkit/src/Installer/InstallationReport.php b/src/Toolkit/src/Installer/InstallationReport.php new file mode 100644 index 00000000000..975fbe1bd75 --- /dev/null +++ b/src/Toolkit/src/Installer/InstallationReport.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\File\File; + +/** + * Represents the output after an installation. + * + * @internal + * + * @author Hugo Alliaume + */ +final class InstallationReport +{ + /** + * @param array $newFiles + * @param array $suggestedPhpPackages + */ + public function __construct( + public readonly array $newFiles, + public readonly array $suggestedPhpPackages, + ) { + } +} diff --git a/src/Toolkit/src/Installer/Installer.php b/src/Toolkit/src/Installer/Installer.php new file mode 100644 index 00000000000..816576e549f --- /dev/null +++ b/src/Toolkit/src/Installer/Installer.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Kit\Kit; + +final class Installer +{ + private PoolResolver $poolResolver; + + /** + * @param \Closure(string):bool $askConfirmation + */ + public function __construct( + private readonly Filesystem $filesystem, + private readonly \Closure $askConfirmation, + ) { + $this->poolResolver = new PoolResolver(); + } + + public function installComponent(Kit $kit, Component $component, string $destinationPath, bool $force): InstallationReport + { + $pool = $this->poolResolver->resolveForComponent($kit, $component); + $output = $this->handlePool($pool, $kit, $destinationPath, $force); + + return $output; + } + + /** + * @param non-empty-string $destinationPath + */ + private function handlePool(Pool $pool, Kit $kit, string $destinationPath, bool $force): InstallationReport + { + $installedFiles = []; + + foreach ($pool->getFiles() as $file) { + if ($this->installFile($kit, $file, $destinationPath, $force)) { + $installedFiles[] = $file; + } + } + + return new InstallationReport(newFiles: $installedFiles, suggestedPhpPackages: $pool->getPhpPackageDependencies()); + } + + /** + * @param non-empty-string $destinationPath + */ + private function installFile(Kit $kit, File $file, string $destinationPath, bool $force): bool + { + $componentPath = Path::join($kit->path, $file->relativePathNameToKit); + $componentDestinationPath = Path::join($destinationPath, $file->relativePathName); + + if ($this->filesystem->exists($componentDestinationPath) && !$force) { + if (!($this->askConfirmation)(\sprintf('File "%s" already exists. Do you want to overwrite it?', $componentDestinationPath))) { + return false; + } + } + + $this->filesystem->copy($componentPath, $componentDestinationPath, $force); + + return true; + } +} diff --git a/src/Toolkit/src/Installer/Pool.php b/src/Toolkit/src/Installer/Pool.php new file mode 100644 index 00000000000..545b8c25f26 --- /dev/null +++ b/src/Toolkit/src/Installer/Pool.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\File\File; + +/** + * Represents a pool of files and dependencies to be installed. + * + * @internal + * + * @author Hugo Alliaume + */ +final class Pool +{ + /** + * @var array + */ + private array $files = []; + + /** + * @param array $files + */ + private array $phpPackageDependencies = []; + + public function addFile(File $file): void + { + $this->files[$file->relativePathName] ??= $file; + } + + /** + * @return array + */ + public function getFiles(): array + { + return $this->files; + } + + public function addPhpPackageDependency(PhpPackageDependency $dependency): void + { + if (isset($this->phpPackageDependencies[$dependency->name]) && $dependency->isHigherThan($this->phpPackageDependencies[$dependency->name])) { + $this->phpPackageDependencies[$dependency->name] = $dependency; + + return; + } + + $this->phpPackageDependencies[$dependency->name] = $dependency; + } + + /** + * @return array + */ + public function getPhpPackageDependencies(): array + { + return $this->phpPackageDependencies; + } +} diff --git a/src/Toolkit/src/Installer/PoolResolver.php b/src/Toolkit/src/Installer/PoolResolver.php new file mode 100644 index 00000000000..00edbf661f2 --- /dev/null +++ b/src/Toolkit/src/Installer/PoolResolver.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\Kit\Kit; + +final class PoolResolver +{ + public function resolveForComponent(Kit $kit, Component $component): Pool + { + $pool = new Pool(); + + // Process the component and its dependencies + $componentsStack = [$component]; + $visitedComponents = new \SplObjectStorage(); + + while (!empty($componentsStack)) { + $currentComponent = array_pop($componentsStack); + + // Skip circular references + if ($visitedComponents->contains($currentComponent)) { + continue; + } + + $visitedComponents->attach($currentComponent); + + foreach ($currentComponent->files as $file) { + $pool->addFile($file); + } + + foreach ($currentComponent->getDependencies() as $dependency) { + if ($dependency instanceof ComponentDependency) { + $componentsStack[] = $kit->getComponent($dependency->name); + } elseif ($dependency instanceof PhpPackageDependency) { + $pool->addPhpPackageDependency($dependency); + } elseif ($dependency instanceof StimulusControllerDependency) { + if (null === $stimulusController = $kit->getStimulusController($dependency->name)) { + throw new \RuntimeException(\sprintf('Stimulus controller "%s" not found.', $dependency->name)); + } + + foreach ($stimulusController->files as $file) { + $pool->addFile($file); + } + } else { + throw new \RuntimeException(\sprintf('Unknown dependency type: %s', $dependency::class)); + } + } + } + + return $pool; + } +} diff --git a/src/Toolkit/src/Kit/Kit.php b/src/Toolkit/src/Kit/Kit.php index 5c668e1100d..542d6b759d2 100644 --- a/src/Toolkit/src/Kit/Kit.php +++ b/src/Toolkit/src/Kit/Kit.php @@ -13,7 +13,8 @@ use Symfony\Component\Filesystem\Path; use Symfony\UX\Toolkit\Assert; -use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Asset\StimulusController; /** * @internal @@ -23,23 +24,25 @@ final class Kit { /** - * @param non-empty-string $path - * @param non-empty-string $name - * @param non-empty-string $homepage - * @param list $authors - * @param non-empty-string $license - * @param list $components + * @param non-empty-string $path + * @param non-empty-string $name + * @param non-empty-string|null $homepage + * @param list|null $authors + * @param non-empty-string|null $license + * @param list $components + * @param list $stimulusControllers */ public function __construct( public readonly string $path, public readonly string $name, - public readonly string $homepage, - public readonly array $authors, - public readonly string $license, + public readonly ?string $homepage = null, + public readonly array $authors = [], + public readonly ?string $license = null, public readonly ?string $description = null, public readonly ?string $uxIcon = null, public ?string $installAsMarkdown = null, private array $components = [], + private array $stimulusControllers = [], ) { Assert::kitName($this->name); @@ -47,7 +50,7 @@ public function __construct( throw new \InvalidArgumentException(\sprintf('Kit path "%s" is not absolute.', $this->path)); } - if (!filter_var($this->homepage, \FILTER_VALIDATE_URL)) { + if (null !== $this->homepage && !filter_var($this->homepage, \FILTER_VALIDATE_URL)) { throw new \InvalidArgumentException(\sprintf('Invalid homepage URL "%s".', $this->homepage)); } } @@ -81,4 +84,31 @@ public function getComponent(string $name): ?Component return null; } + + public function addStimulusController(StimulusController $stimulusController): void + { + foreach ($this->stimulusControllers as $existingStimulusController) { + if ($existingStimulusController->name === $stimulusController->name) { + throw new \InvalidArgumentException(\sprintf('Stimulus controller "%s" is already registered in the kit.', $stimulusController->name)); + } + } + + $this->stimulusControllers[] = $stimulusController; + } + + public function getStimulusControllers(): array + { + return $this->stimulusControllers; + } + + public function getStimulusController(string $name): ?StimulusController + { + foreach ($this->stimulusControllers as $stimulusController) { + if ($stimulusController->name === $name) { + return $stimulusController; + } + } + + return null; + } } diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php index d8190e4a455..88fc17a2f7d 100644 --- a/src/Toolkit/src/Kit/KitFactory.php +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -13,23 +13,17 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; -use Symfony\Component\Finder\Finder; -use Symfony\UX\Toolkit\Component\Component; -use Symfony\UX\Toolkit\Dependency\DependenciesResolver; -use Symfony\UX\Toolkit\File\Doc; -use Symfony\UX\Toolkit\File\File; -use Symfony\UX\Toolkit\File\FileType; /** * @internal * * @author Hugo Alliaume */ -final readonly class KitFactory +final class KitFactory { public function __construct( - private Filesystem $filesystem, - private DependenciesResolver $dependencyResolver, + private readonly Filesystem $filesystem, + private readonly KitSynchronizer $kitSynchronizer, ) { } @@ -63,66 +57,8 @@ public function createKitFromAbsolutePath(string $absolutePath): Kit uxIcon: $manifest['ux-icon'] ?? null, ); - $this->synchronizeKit($kit); + $this->kitSynchronizer->synchronize($kit); return $kit; } - - private function synchronizeKit(Kit $kit): void - { - $this->synchronizeKitComponents($kit); - $this->synchronizeKitDocumentation($kit); - } - - private function synchronizeKitComponents(Kit $kit): void - { - $componentsPath = Path::join('templates', 'components'); - $finder = (new Finder()) - ->in($kit->path) - ->files() - ->path($componentsPath) - ->sortByName() - ->name('*.html.twig') - ; - - foreach ($finder as $file) { - $relativePathNameToKit = $file->getRelativePathname(); - $relativePathName = str_replace($componentsPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); - $componentName = $this->extractComponentName($relativePathName); - $component = new Component( - name: $componentName, - files: [new File( - type: FileType::Twig, - relativePathNameToKit: $relativePathNameToKit, - relativePathName: $relativePathName, - )], - ); - - $kit->addComponent($component); - } - - $this->dependencyResolver->resolveDependencies($kit); - } - - private static function extractComponentName(string $pathnameRelativeToKit): string - { - return str_replace(['.html.twig', '/'], ['', ':'], $pathnameRelativeToKit); - } - - private function synchronizeKitDocumentation(Kit $kit): void - { - // Read INSTALL.md if exists - $fileInstall = Path::join($kit->path, 'INSTALL.md'); - if ($this->filesystem->exists($fileInstall)) { - $kit->installAsMarkdown = $this->filesystem->readFile($fileInstall); - } - - // Iterate over Component and find their documentation - foreach ($kit->getComponents() as $component) { - $docPath = Path::join($kit->path, 'docs', 'components', $component->name.'.md'); - if ($this->filesystem->exists($docPath)) { - $component->doc = new Doc($this->filesystem->readFile($docPath)); - } - } - } } diff --git a/src/Toolkit/src/Kit/KitSynchronizer.php b/src/Toolkit/src/Kit/KitSynchronizer.php new file mode 100644 index 00000000000..a3aa36d7091 --- /dev/null +++ b/src/Toolkit/src/Kit/KitSynchronizer.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Kit; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Asset\StimulusController; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\File\Doc; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class KitSynchronizer +{ + /** + * @see https://regex101.com/r/WasRGf/1 + */ + private const RE_TWIG_COMPONENT_REFERENCES = '/[a-zA-Z0-9:_-]+)/'; + + /** + * @see https://regex101.com/r/inIBID/1 + */ + private const RE_STIMULUS_CONTROLLER_REFERENCES = '/data-controller=(["\'])(?P.+?)\1/'; + + public function __construct( + private readonly Filesystem $filesystem, + ) { + } + + public function synchronize(Kit $kit): void + { + $this->synchronizeComponents($kit); + $this->synchronizeStimulusControllers($kit); + $this->synchronizeDocumentation($kit); + } + + private function synchronizeComponents(Kit $kit): void + { + $componentsPath = Path::join('templates', 'components'); + $finder = (new Finder()) + ->in($kit->path) + ->files() + ->path($componentsPath) + ->sortByName() + ->name('*.html.twig') + ; + + foreach ($finder as $file) { + $relativePathNameToKit = $file->getRelativePathname(); + $relativePathName = str_replace($componentsPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); + $componentName = $this->extractComponentName($relativePathName); + $component = new Component( + name: $componentName, + files: [new File( + type: FileType::Twig, + relativePathNameToKit: $relativePathNameToKit, + relativePathName: $relativePathName, + )], + ); + + $kit->addComponent($component); + } + + foreach ($kit->getComponents() as $component) { + $this->resolveComponentDependencies($kit, $component); + } + } + + private function resolveComponentDependencies(Kit $kit, Component $component): void + { + // Find dependencies based on component name + foreach ($kit->getComponents() as $otherComponent) { + if ($component->name === $otherComponent->name) { + continue; + } + + // Find components with the component name as a prefix + if (str_starts_with($otherComponent->name, $component->name.':')) { + $component->addDependency(new ComponentDependency($otherComponent->name)); + } + } + + // Find dependencies based on file content + foreach ($component->files as $file) { + $fileContent = $this->filesystem->readFile(Path::join($kit->path, $file->relativePathNameToKit)); + + if (FileType::Twig === $file->type) { + if (str_contains($fileContent, 'html_cva')) { + $component->addDependency(new PhpPackageDependency('twig/extra-bundle')); + $component->addDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0))); + } + + if (str_contains($fileContent, 'tailwind_merge')) { + $component->addDependency(new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra')); + } + + if (str_contains($fileContent, 'name) { + continue; + } + + if ('ux:icon' === strtolower($componentReferenceName)) { + $component->addDependency(new PhpPackageDependency('symfony/ux-icons')); + } elseif ('ux:map' === strtolower($componentReferenceName)) { + $component->addDependency(new PhpPackageDependency('symfony/ux-map')); + } elseif (null === $componentReference = $kit->getComponent($componentReferenceName)) { + throw new \RuntimeException(\sprintf('Component "%s" not found in component "%s" (file "%s")', $componentReferenceName, $component->name, $file->relativePathNameToKit)); + } else { + $component->addDependency(new ComponentDependency($componentReference->name)); + } + } + } + + if (str_contains($fileContent, 'data-controller=') && preg_match_all(self::RE_STIMULUS_CONTROLLER_REFERENCES, $fileContent, $matches)) { + $controllersName = array_filter(array_map(fn (string $name) => trim($name), explode(' ', $matches['controllersName'][0]))); + foreach ($controllersName as $controllerReferenceName) { + $component->addDependency(new StimulusControllerDependency($controllerReferenceName)); + } + } + } + } + } + + private function synchronizeStimulusControllers(Kit $kit): void + { + $controllersPath = Path::join('assets', 'controllers'); + $finder = (new Finder()) + ->in($kit->path) + ->files() + ->path($controllersPath) + ->sortByName() + ->name('*.js') + ; + + foreach ($finder as $file) { + $relativePathNameToKit = $file->getRelativePathname(); + $relativePathName = str_replace($controllersPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); + $controllerName = $this->extractStimulusControllerName($relativePathName); + $controller = new StimulusController( + name: $controllerName, + files: [new File( + type: FileType::StimulusController, + relativePathNameToKit: $relativePathNameToKit, + relativePathName: $relativePathName, + )], + ); + + $kit->addStimulusController($controller); + } + } + + private function synchronizeDocumentation(Kit $kit): void + { + // Read INSTALL.md if exists + $fileInstall = Path::join($kit->path, 'INSTALL.md'); + if ($this->filesystem->exists($fileInstall)) { + $kit->installAsMarkdown = $this->filesystem->readFile($fileInstall); + } + + // Iterate over Component and find their documentation + foreach ($kit->getComponents() as $component) { + $docPath = Path::join($kit->path, 'docs', 'components', $component->name.'.md'); + if ($this->filesystem->exists($docPath)) { + $component->doc = new Doc($this->filesystem->readFile($docPath)); + } + } + } + + private static function extractComponentName(string $pathnameRelativeToKit): string + { + return str_replace(['.html.twig', '/'], ['', ':'], $pathnameRelativeToKit); + } + + private static function extractStimulusControllerName(string $pathnameRelativeToKit): string + { + return str_replace(['_controller.js', '-controller.js', '/', '_'], ['', '', '--', '-'], $pathnameRelativeToKit); + } +} diff --git a/src/Toolkit/src/Registry/GitHubRegistryIdentity.php b/src/Toolkit/src/Registry/GitHubRegistryIdentity.php index cce44afd6f2..58beeddd40e 100644 --- a/src/Toolkit/src/Registry/GitHubRegistryIdentity.php +++ b/src/Toolkit/src/Registry/GitHubRegistryIdentity.php @@ -17,7 +17,7 @@ * @author Jean-François Lépine * @author Hugo Alliaume */ -final readonly class GitHubRegistryIdentity +final class GitHubRegistryIdentity { /** * @param non-empty-string $authorName @@ -25,9 +25,9 @@ * @param non-empty-string $version */ private function __construct( - public string $authorName, - public string $repositoryName, - public string $version, + public readonly string $authorName, + public readonly string $repositoryName, + public readonly string $version, ) { } diff --git a/src/Toolkit/src/Registry/LocalRegistry.php b/src/Toolkit/src/Registry/LocalRegistry.php index 7295582de57..d7f849fa9b3 100644 --- a/src/Toolkit/src/Registry/LocalRegistry.php +++ b/src/Toolkit/src/Registry/LocalRegistry.php @@ -22,7 +22,7 @@ * @author Jean-François Lépine * @author Hugo Alliaume */ -final readonly class LocalRegistry implements Registry +final class LocalRegistry implements Registry { public static function supports(string $kitName): bool { @@ -30,9 +30,9 @@ public static function supports(string $kitName): bool } public function __construct( - private KitFactory $kitFactory, - private Filesystem $filesystem, - private string $projectDir, + private readonly KitFactory $kitFactory, + private readonly Filesystem $filesystem, + private readonly string $projectDir, ) { } diff --git a/src/Toolkit/src/Registry/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php index 29809a4bbc0..d9ed7151899 100644 --- a/src/Toolkit/src/Registry/RegistryFactory.php +++ b/src/Toolkit/src/Registry/RegistryFactory.php @@ -19,10 +19,10 @@ * @author Jean-François Lépine * @author Hugo Alliaume */ -final readonly class RegistryFactory +final class RegistryFactory { public function __construct( - private ContainerInterface $registries, + private readonly ContainerInterface $registries, ) { } diff --git a/src/Toolkit/tests/AssertTest.php b/src/Toolkit/tests/AssertTest.php index 2c8cc114dd5..5294e7dd83d 100644 --- a/src/Toolkit/tests/AssertTest.php +++ b/src/Toolkit/tests/AssertTest.php @@ -87,8 +87,9 @@ public static function provideInvalidKitNames(): \Generator */ public function testValidComponentName(string $name): void { + $this->expectNotToPerformAssertions(); + Assert::componentName($name); - $this->addToAssertionCount(1); } public static function provideValidComponentNames(): iterable @@ -164,7 +165,7 @@ public static function provideValidPhpPackageNames(): iterable public function testInvalidPhpPackageName(string $name): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('Invalid package name "%s".', $name)); + $this->expectExceptionMessage(\sprintf('Invalid PHP package name "%s".', $name)); Assert::phpPackageName($name); } @@ -176,4 +177,45 @@ public static function provideInvalidPhpPackageNames(): iterable yield ['twig/html-extra/']; yield ['twig/html-extra/twig']; } + + /** + * @dataProvider provideValidStimulusControllerNames + */ + public function testValidStimulusControllerName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::stimulusControllerName($name); + } + + public static function provideValidStimulusControllerNames(): iterable + { + yield ['my-controller']; + yield ['users--list-item']; + yield ['controller']; + yield ['controller-with-numbers-123']; + } + + /** + * @dataProvider provideInvalidStimulusControllerNames + */ + public function testInvalidStimulusControllerName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid Stimulus controller name "%s".', $name)); + + Assert::stimulusControllerName($name); + } + + public static function provideInvalidStimulusControllerNames(): iterable + { + yield ['']; + yield ['my_controller']; + yield ['my-controller-']; + yield ['-my-controller']; + yield ['my-controller/qsd']; + yield ['my-controller@qsd']; + yield ['my-controller.qsd']; + yield ['my-controller:qsd']; + } } diff --git a/src/Toolkit/tests/Kit/ComponentTest.php b/src/Toolkit/tests/Asset/ComponentTest.php similarity index 98% rename from src/Toolkit/tests/Kit/ComponentTest.php rename to src/Toolkit/tests/Asset/ComponentTest.php index f88c4fb9b46..27378fa1d1e 100644 --- a/src/Toolkit/tests/Kit/ComponentTest.php +++ b/src/Toolkit/tests/Asset/ComponentTest.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit; +namespace Symfony\UX\Toolkit\Tests\Asset; use PHPUnit\Framework\TestCase; -use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Asset\Component; use Symfony\UX\Toolkit\Dependency\ComponentDependency; use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; use Symfony\UX\Toolkit\Dependency\Version; diff --git a/src/Toolkit/tests/Asset/StimulusControllerTest.php b/src/Toolkit/tests/Asset/StimulusControllerTest.php new file mode 100644 index 00000000000..ee759ae120f --- /dev/null +++ b/src/Toolkit/tests/Asset/StimulusControllerTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Asset; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Asset\StimulusController; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; + +final class StimulusControllerTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $stimulusController = new StimulusController('clipboard', [ + new File(FileType::StimulusController, 'assets/controllers/clipboard_controller.js', 'clipboard_controller.js'), + ]); + + $this->assertSame('clipboard', $stimulusController->name); + } + + public function testShouldFailIfStimulusControllerNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Stimulus controller name "invalid_controller".'); + + new StimulusController('invalid_controller', [new File(FileType::StimulusController, 'assets/controllers/invalid_controller.js', 'invalid_controller.js')]); + } + + public function testShouldFailIfStimulusControllerHasNoFiles(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Stimulus controller "clipboard" has no files.'); + + new StimulusController('clipboard', []); + } +} diff --git a/src/Toolkit/tests/Command/InstallComponentCommandTest.php b/src/Toolkit/tests/Command/InstallComponentCommandTest.php index 9ac82b58e90..a0d0b16d928 100644 --- a/src/Toolkit/tests/Command/InstallComponentCommandTest.php +++ b/src/Toolkit/tests/Command/InstallComponentCommandTest.php @@ -50,14 +50,16 @@ public function testShouldAbleToInstallComponentTableAndItsDependencies(): void $this->assertFileDoesNotExist($expectedFile); } - $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) + $testCommand = $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) ->execute() ->assertSuccessful() - ->assertOutputContains('The component "Table" has been installed.') + ->assertOutputContains('Installing component Table...') + ->assertOutputContains('[OK] The component has been installed.') ; - // Files should be created, + // Files should be created foreach ($expectedFiles as $fileName => $expectedFile) { + $testCommand->assertOutputContains($fileName); $this->assertFileExists($expectedFile); $this->assertEquals(file_get_contents(__DIR__.'/../../kits/shadcn/templates/components/'.$fileName), file_get_contents($expectedFile)); } @@ -96,7 +98,7 @@ public function testShouldFailWhenComponentDoesNotExist(): void ->assertOutputContains('The component "Unknown" does not exist.'); } - public function testShouldFailWhenComponentFileAlreadyExistsInNonInteractiveMode(): void + public function testShouldWarnWhenComponentFileAlreadyExistsInNonInteractiveMode(): void { $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); mkdir($destination); @@ -109,7 +111,7 @@ public function testShouldFailWhenComponentFileAlreadyExistsInNonInteractiveMode $this->consoleCommand('ux:toolkit:install-component Badge --destination='.$destination) ->execute() ->assertFaulty() - ->assertOutputContains('The component "Badge" already exists.') + ->assertOutputContains('[WARNING] The component has not been installed.') ; } } diff --git a/src/Toolkit/tests/Kit/Dependency/ComponentDependencyTest.php b/src/Toolkit/tests/Dependency/ComponentDependencyTest.php similarity index 94% rename from src/Toolkit/tests/Kit/Dependency/ComponentDependencyTest.php rename to src/Toolkit/tests/Dependency/ComponentDependencyTest.php index dd434ae2bb1..54dc3a42eb3 100644 --- a/src/Toolkit/tests/Kit/Dependency/ComponentDependencyTest.php +++ b/src/Toolkit/tests/Dependency/ComponentDependencyTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit\Dependency; +namespace Symfony\UX\Toolkit\Tests\Dependency; use PHPUnit\Framework\TestCase; use Symfony\UX\Toolkit\Dependency\ComponentDependency; diff --git a/src/Toolkit/tests/Kit/Dependency/PhpPackageDependencyTest.php b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php similarity index 90% rename from src/Toolkit/tests/Kit/Dependency/PhpPackageDependencyTest.php rename to src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php index a6aa6c17aea..b4fb1569230 100644 --- a/src/Toolkit/tests/Kit/Dependency/PhpPackageDependencyTest.php +++ b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit\Dependency; +namespace Symfony\UX\Toolkit\Tests\Dependency; use PHPUnit\Framework\TestCase; use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; @@ -32,7 +32,7 @@ public function testShouldBeInstantiable(): void public function testShouldFailIfPackageNameIsInvalid(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid package name "/foo".'); + $this->expectExceptionMessage('Invalid PHP package name "/foo".'); new PhpPackageDependency('/foo'); } diff --git a/src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php b/src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php new file mode 100644 index 00000000000..2e6b6feb80b --- /dev/null +++ b/src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Dependency; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; + +final class StimulusControllerDependencyTest extends TestCase +{ + public function testShouldBeInstantiable(): void + { + $dependency = new StimulusControllerDependency('clipboard'); + + $this->assertSame('clipboard', $dependency->name); + $this->assertSame('clipboard', (string) $dependency); + } + + public function testShouldFailIfComponentNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Stimulus controller name "my_Controller".'); + + new StimulusControllerDependency('my_Controller'); + } +} diff --git a/src/Toolkit/tests/Kit/Dependency/VersionTest.php b/src/Toolkit/tests/Dependency/VersionTest.php similarity index 96% rename from src/Toolkit/tests/Kit/Dependency/VersionTest.php rename to src/Toolkit/tests/Dependency/VersionTest.php index 7f3e3f5dfbd..3189047ba6b 100644 --- a/src/Toolkit/tests/Kit/Dependency/VersionTest.php +++ b/src/Toolkit/tests/Dependency/VersionTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit\Dependency; +namespace Symfony\UX\Toolkit\Tests\Dependency; use PHPUnit\Framework\TestCase; use Symfony\UX\Toolkit\Dependency\Version; diff --git a/src/Toolkit/tests/Kit/DocTest.php b/src/Toolkit/tests/File/DocTest.php similarity index 94% rename from src/Toolkit/tests/Kit/DocTest.php rename to src/Toolkit/tests/File/DocTest.php index e455c363577..2cfb82df4b8 100644 --- a/src/Toolkit/tests/Kit/DocTest.php +++ b/src/Toolkit/tests/File/DocTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit; +namespace Symfony\UX\Toolkit\Tests\File; use PHPUnit\Framework\TestCase; use Symfony\UX\Toolkit\File\Doc; diff --git a/src/Toolkit/tests/Kit/File/FileTest.php b/src/Toolkit/tests/File/FileTest.php similarity index 98% rename from src/Toolkit/tests/Kit/File/FileTest.php rename to src/Toolkit/tests/File/FileTest.php index 41e69c8dfc3..8ee8a6ccb75 100644 --- a/src/Toolkit/tests/Kit/File/FileTest.php +++ b/src/Toolkit/tests/File/FileTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit\File; +namespace Symfony\UX\Toolkit\Tests\File; use PHPUnit\Framework\TestCase; use Symfony\UX\Toolkit\File\File; diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php index c289a95cc66..7b9b95ff415 100644 --- a/src/Toolkit/tests/Fixtures/Kernel.php +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -57,10 +57,10 @@ protected function configureContainer(ContainerConfigurator $container): void ]); $container->services() - ->alias('ux_toolkit.kit.factory', '.ux_toolkit.kit.factory') + ->alias('ux_toolkit.kit.kit_factory', '.ux_toolkit.kit.kit_factory') ->public() - ->alias('ux_toolkit.kit.dependencies_resolver', '.ux_toolkit.dependency.dependencies_resolver') + ->alias('ux_toolkit.kit.kit_synchronizer', '.ux_toolkit.kit.kit_synchronizer') ->public() ->alias('ux_toolkit.registry.factory', '.ux_toolkit.registry.factory') diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json new file mode 100644 index 00000000000..5a1be5a1daa --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "With Circular Components Dependencies", + "description": "Kit used as a test fixture.", + "license": "MIT", + "homepage": "https://ux.symfony.com/", + "authors": ["Symfony UX Community"] +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig new file mode 100644 index 00000000000..170566e3300 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig @@ -0,0 +1 @@ + diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig new file mode 100644 index 00000000000..ee363988603 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig @@ -0,0 +1 @@ + diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig new file mode 100644 index 00000000000..bb94edf19d0 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig @@ -0,0 +1,4 @@ +{% props render_child = false %} +{% if render_child %} + +{% endif %} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // … +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // … +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // … +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // … +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json new file mode 100644 index 00000000000..f8f4ff66455 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "With Stimulus Controllers", + "description": "Kit used as a test fixture.", + "license": "MIT", + "homepage": "https://ux.symfony.com/", + "authors": ["Symfony UX Community"] +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig new file mode 100644 index 00000000000..171027bc04e --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig @@ -0,0 +1 @@ +
      {% block content %}
      diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig new file mode 100644 index 00000000000..7b4e9a8e332 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig @@ -0,0 +1 @@ +
      {% block content %}
      diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig new file mode 100644 index 00000000000..37995d5bff0 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig @@ -0,0 +1 @@ +
      {% block content %}
      diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig new file mode 100644 index 00000000000..7c7a15bde17 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig @@ -0,0 +1 @@ +
      {% block content %}
      diff --git a/src/Toolkit/tests/Kit/ComponentInstallerTest.php b/src/Toolkit/tests/Installer/InstallerTest.php similarity index 63% rename from src/Toolkit/tests/Kit/ComponentInstallerTest.php rename to src/Toolkit/tests/Installer/InstallerTest.php index 23ed184a80d..554ebbbd74e 100644 --- a/src/Toolkit/tests/Kit/ComponentInstallerTest.php +++ b/src/Toolkit/tests/Installer/InstallerTest.php @@ -9,17 +9,15 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Toolkit\Tests\Kit; +namespace Symfony\UX\Toolkit\Tests\Installer; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; -use Symfony\UX\Toolkit\Component\ComponentInstaller; -use Symfony\UX\Toolkit\Dependency\ComponentDependency; -use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; +use Symfony\UX\Toolkit\Installer\Installer; use Symfony\UX\Toolkit\Kit\Kit; -final class ComponentInstallerTest extends KernelTestCase +final class InstallerTest extends KernelTestCase { private Filesystem $filesystem; private string $tmpDir; @@ -37,7 +35,7 @@ protected function setUp(): void public function testCanInstallComponent(): void { - $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), fn () => throw new \BadFunctionCallException('The installer should not ask for confirmation since the file does not exist.')); $kit = $this->createKit('shadcn'); $this->assertFileDoesNotExist($this->tmpDir.'/Button.html.twig'); @@ -45,46 +43,49 @@ public function testCanInstallComponent(): void $component = $kit->getComponent('Button'); $this->assertNotNull($component); - $componentInstaller->install($kit, $component, $this->tmpDir); + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); } - public function testShouldFailIfComponentAlreadyExists(): void + public function testShouldAskIfFileAlreadyExists(): void { - $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $askedCount = 0; + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), function () use (&$askedCount) { + ++$askedCount; + + return true; + }); $kit = $this->createKit('shadcn'); $component = $kit->getComponent('Button'); $this->assertNotNull($component); - $componentInstaller->install($kit, $component, $this->tmpDir); + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + $this->assertSame(0, $askedCount); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); - $this->expectException(ComponentAlreadyExistsException::class); - $this->expectExceptionMessage('The component "Button" already exists.'); - - $componentInstaller->install($kit, $component, $this->tmpDir); + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + $this->assertSame(1, $askedCount); } public function testCanInstallComponentIfForced(): void { - $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), fn () => throw new \BadFunctionCallException('The installer should not ask for confirmation since the file does not exist.')); $kit = $this->createKit('shadcn'); $component = $kit->getComponent('Button'); $this->assertNotNull($component); - $componentInstaller->install($kit, $component, $this->tmpDir); + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); - // No exception should be thrown, the file should be overwritten - $componentInstaller->install($kit, $component, $this->tmpDir, true); + $componentInstaller->installComponent($kit, $component, $this->tmpDir, true); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); @@ -92,7 +93,7 @@ public function testCanInstallComponentIfForced(): void public function testCanInstallComponentAndItsComponentDependencies(): void { - $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), fn () => throw new \BadFunctionCallException('The installer should not ask for confirmation since the file does not exist.')); $kit = $this->createKit('shadcn'); $expectedFiles = [ @@ -104,25 +105,17 @@ public function testCanInstallComponentAndItsComponentDependencies(): void 'Table/Head.html.twig' => $this->tmpDir.'/Table/Head.html.twig', 'Table/Header.html.twig' => $this->tmpDir.'/Table/Header.html.twig', 'Table/Row.html.twig' => $this->tmpDir.'/Table/Row.html.twig', + 'Button.html.twig' => $this->tmpDir.'/Button.html.twig', + 'Input.html.twig' => $this->tmpDir.'/Input.html.twig', ]; foreach ($expectedFiles as $expectedFile) { $this->assertFileDoesNotExist($expectedFile); } - $component = $kit->getComponent('Table'); - $this->assertNotNull($component); - - // Install the component and its dependencies - $componentInstaller->install($kit, $component, $this->tmpDir); - foreach ($component->getDependencies() as $dependency) { - if ($dependency instanceof ComponentDependency) { - $dependencyComponent = $kit->getComponent($dependency->name); - $this->assertNotNull($dependencyComponent); - - $componentInstaller->install($kit, $dependencyComponent, $this->tmpDir); - } - } + $componentInstaller->installComponent($kit, $kit->getComponent('Table'), $this->tmpDir, false); + $componentInstaller->installComponent($kit, $kit->getComponent('Button'), $this->tmpDir, false); + $componentInstaller->installComponent($kit, $kit->getComponent('Input'), $this->tmpDir, false); foreach ($expectedFiles as $fileName => $expectedFile) { $this->assertFileExists($expectedFile); @@ -132,6 +125,6 @@ public function testCanInstallComponentAndItsComponentDependencies(): void private function createKit(string $kitName): Kit { - return self::getContainer()->get('ux_toolkit.kit.factory')->createKitFromAbsolutePath(Path::join(__DIR__, '../../kits', $kitName)); + return self::getContainer()->get('ux_toolkit.kit.kit_factory')->createKitFromAbsolutePath(Path::join(__DIR__, '../../kits', $kitName)); } } diff --git a/src/Toolkit/tests/Installer/PoolResolverTest.php b/src/Toolkit/tests/Installer/PoolResolverTest.php new file mode 100644 index 00000000000..deded51aae0 --- /dev/null +++ b/src/Toolkit/tests/Installer/PoolResolverTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Installer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Installer\PoolResolver; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; + +final class PoolResolverTest extends TestCase +{ + public function testCanResolveDependencies(): void + { + $kitSynchronizer = new KitSynchronizer(new Filesystem()); + $kit = new Kit(Path::join(__DIR__, '../../kits/shadcn'), 'shadcn'); + $kitSynchronizer->synchronize($kit); + + $poolResolver = new PoolResolver(); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('Button')); + + $this->assertCount(1, $pool->getFiles()); + $this->assertArrayHasKey('Button.html.twig', $pool->getFiles()); + $this->assertCount(3, $pool->getPhpPackageDependencies()); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('Table')); + + $this->assertCount(8, $pool->getFiles()); + $this->assertArrayHasKey('Table.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Row.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Cell.html.twig', $pool->getFiles()); + $this->assertInstanceOf(File::class, $pool->getFiles()['Table/Head.html.twig']); + $this->assertArrayHasKey('Table/Header.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Footer.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Caption.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Body.html.twig', $pool->getFiles()); + $this->assertCount(1, $pool->getPhpPackageDependencies()); + } + + public function testCanHandleCircularComponentDependencies(): void + { + $kitSynchronizer = new KitSynchronizer(new Filesystem()); + $kit = new Kit(Path::join(__DIR__, '../Fixtures/kits/with-circular-components-dependencies'), 'with-circular-components-dependencies'); + $kitSynchronizer->synchronize($kit); + + $poolResolver = new PoolResolver(); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('A')); + + $this->assertCount(3, $pool->getFiles()); + $this->assertArrayHasKey('A.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('B.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('C.html.twig', $pool->getFiles()); + $this->assertCount(0, $pool->getPhpPackageDependencies()); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('B')); + + $this->assertCount(3, $pool->getFiles()); + $this->assertArrayHasKey('A.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('B.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('C.html.twig', $pool->getFiles()); + $this->assertCount(0, $pool->getPhpPackageDependencies()); + } +} diff --git a/src/Toolkit/tests/Installer/PoolTest.php b/src/Toolkit/tests/Installer/PoolTest.php new file mode 100644 index 00000000000..3e52a8fdadc --- /dev/null +++ b/src/Toolkit/tests/Installer/PoolTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Installer; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; +use Symfony\UX\Toolkit\Installer\Pool; + +final class PoolTest extends TestCase +{ + public function testCanAddFiles(): void + { + $pool = new Pool(); + + $this->assertCount(0, $pool->getFiles()); + + $pool->addFile(new File(FileType::Twig, 'path/to/file.html.twig', 'file.html.twig')); + $pool->addFile(new File(FileType::Twig, 'path/to/another-file.html.twig', 'another-file.html.twig')); + + $this->assertCount(2, $pool->getFiles()); + } + + public function testCantAddSameFileTwice(): void + { + $pool = new Pool(); + + $pool->addFile(new File(FileType::Twig, 'path/to/file.html.twig', 'file.html.twig')); + $pool->addFile(new File(FileType::Twig, 'path/to/file.html.twig', 'file.html.twig')); + + $this->assertCount(1, $pool->getFiles()); + } + + public function testCanAddPhpPackageDependencies(): void + { + $pool = new Pool(); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra')); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + } + + public function testCantAddSamePhpPackageDependencyTwice(): void + { + $pool = new Pool(); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra')); + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra')); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + } + + public function testCanAddPhpPackageDependencyWithHigherVersion(): void + { + $pool = new Pool(); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 11, 0))); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + $this->assertEquals('twig/html-extra:^3.11.0', (string) $pool->getPhpPackageDependencies()['twig/html-extra']); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0))); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + $this->assertEquals('twig/html-extra:^3.12.0', (string) $pool->getPhpPackageDependencies()['twig/html-extra']); + } +} diff --git a/src/Toolkit/tests/Kit/DependenciesResolverTest.php b/src/Toolkit/tests/Kit/DependenciesResolverTest.php deleted file mode 100644 index e9476f8a8b2..00000000000 --- a/src/Toolkit/tests/Kit/DependenciesResolverTest.php +++ /dev/null @@ -1,65 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Tests\Kit; - -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Filesystem\Path; -use Symfony\UX\Toolkit\Component\Component; -use Symfony\UX\Toolkit\Dependency\ComponentDependency; -use Symfony\UX\Toolkit\Dependency\DependenciesResolver; -use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; -use Symfony\UX\Toolkit\Dependency\Version; -use Symfony\UX\Toolkit\File\File; -use Symfony\UX\Toolkit\File\FileType; -use Symfony\UX\Toolkit\Kit\Kit; - -final class DependenciesResolverTest extends KernelTestCase -{ - private Filesystem $filesystem; - - protected function setUp(): void - { - parent::setUp(); - - $this->bootKernel(); - $this->filesystem = self::getContainer()->get('filesystem'); - } - - public function testCanResolveDependencies(): void - { - $dependenciesResolver = new DependenciesResolver($this->filesystem); - - $kit = new Kit(Path::join(__DIR__, '../../kits/shadcn'), 'shadcn', 'https://shadcn.com', ['Shadcn'], 'MIT'); - $kit->addComponent($button = new Component('Button', [new File(FileType::Twig, 'templates/components/Button.html.twig', 'Button.html.twig')])); - $kit->addComponent($table = new Component('Table', [new File(FileType::Twig, 'templates/components/Table.html.twig', 'Table.html.twig')])); - $kit->addComponent(new Component('Table:Row', [new File(FileType::Twig, 'templates/components/Table/Row.html.twig', 'Table/Row.html.twig')])); - $kit->addComponent(new Component('Table:Cell', [new File(FileType::Twig, 'templates/components/Table/Cell.html.twig', 'Table/Cell.html.twig')])); - - $this->assertCount(0, $button->getDependencies()); - $this->assertCount(0, $table->getDependencies()); - - $dependenciesResolver->resolveDependencies($kit); - - $this->assertEquals([ - new PhpPackageDependency('twig/extra-bundle'), - new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0)), - new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), - ], $button->getDependencies()); - - $this->assertEquals([ - new ComponentDependency('Table:Row'), - new ComponentDependency('Table:Cell'), - new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), - ], $table->getDependencies()); - } -} diff --git a/src/Toolkit/tests/Kit/KitFactoryTest.php b/src/Toolkit/tests/Kit/KitFactoryTest.php index b30269044ab..7b52e0f1dff 100644 --- a/src/Toolkit/tests/Kit/KitFactoryTest.php +++ b/src/Toolkit/tests/Kit/KitFactoryTest.php @@ -12,8 +12,12 @@ namespace Symfony\UX\Toolkit\Tests\Kit; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Asset\StimulusController; use Symfony\UX\Toolkit\Dependency\ComponentDependency; use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; use Symfony\UX\Toolkit\Kit\KitFactory; final class KitFactoryTest extends KernelTestCase @@ -38,7 +42,7 @@ public function testShouldFailIfKitDoesNotExist(): void $kitFactory->createKitFromAbsolutePath(__DIR__.'/../../kits/does-not-exist'); } - public function testCanCreateKit(): void + public function testCanCreateShadKit(): void { $kitFactory = $this->createKitFactory(); @@ -70,8 +74,36 @@ public function testCanCreateKit(): void , $table->doc->markdownContent); } + public function testCanHandleStimulusControllers(): void + { + $kitFactory = $this->createKitFactory(); + + $kit = $kitFactory->createKitFromAbsolutePath(__DIR__.'/../Fixtures/kits/with-stimulus-controllers'); + + $this->assertNotEmpty($kit->getComponents()); + + // Assert Stimulus Controllers are registered in the Kit + $this->assertNotEmpty($kit->getStimulusControllers()); + $this->assertEquals([ + $clipboard = new StimulusController('clipboard', [new File(FileType::StimulusController, 'assets/controllers/clipboard_controller.js', 'clipboard_controller.js')]), + $datePicker = new StimulusController('date-picker', [new File(FileType::StimulusController, 'assets/controllers/date_picker_controller.js', 'date_picker_controller.js')]), + $localTime = new StimulusController('local-time', [new File(FileType::StimulusController, 'assets/controllers/local-time-controller.js', 'local-time-controller.js')]), + $usersListItem = new StimulusController('users--list-item', [new File(FileType::StimulusController, 'assets/controllers/users/list_item_controller.js', 'users/list_item_controller.js')]), + ], $kit->getStimulusControllers()); + $this->assertEquals($clipboard, $kit->getStimulusController('clipboard')); + $this->assertEquals($datePicker, $kit->getStimulusController('date-picker')); + $this->assertEquals($localTime, $kit->getStimulusController('local-time')); + $this->assertEquals($usersListItem, $kit->getStimulusController('users--list-item')); + + // Assert Stimulus Controllers are marked as Component dependencies + $this->assertEquals([new StimulusControllerDependency('clipboard')], $kit->getComponent('Clipboard')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('date-picker')], $kit->getComponent('DatePicker')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('local-time')], $kit->getComponent('LocalTime')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('users--list-item'), new StimulusControllerDependency('clipboard')], $kit->getComponent('UsersListItem')->getDependencies()); + } + private function createKitFactory(): KitFactory { - return new KitFactory(self::getContainer()->get('filesystem'), self::getContainer()->get('ux_toolkit.kit.dependencies_resolver')); + return new KitFactory(self::getContainer()->get('filesystem'), self::getContainer()->get('ux_toolkit.kit.kit_synchronizer')); } } diff --git a/src/Toolkit/tests/Kit/KitSynchronizerTest.php b/src/Toolkit/tests/Kit/KitSynchronizerTest.php new file mode 100644 index 00000000000..cb6e51f3742 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitSynchronizerTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; + +final class KitSynchronizerTest extends KernelTestCase +{ + private Filesystem $filesystem; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + } + + public function testCanResolveDependencies(): void + { + $kitSynchronizer = new KitSynchronizer($this->filesystem); + $kit = new Kit(Path::join(__DIR__, '../../kits/shadcn'), 'shadcn'); + + $kitSynchronizer->synchronize($kit); + + $this->assertEquals([ + new PhpPackageDependency('twig/extra-bundle'), + new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0)), + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + ], $kit->getComponent('Button')->getDependencies()); + + $this->assertEquals([ + new ComponentDependency('Table:Body'), + new ComponentDependency('Table:Caption'), + new ComponentDependency('Table:Cell'), + new ComponentDependency('Table:Footer'), + new ComponentDependency('Table:Head'), + new ComponentDependency('Table:Header'), + new ComponentDependency('Table:Row'), + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + ], $kit->getComponent('Table')->getDependencies()); + } + + public function testCanResolveStimulusDependencies(): void + { + $kitSynchronizer = new KitSynchronizer($this->filesystem); + $kit = new Kit(Path::join(__DIR__, '../Fixtures/kits/with-stimulus-controllers'), 'kit'); + + $kitSynchronizer->synchronize($kit); + + $this->assertEquals([new StimulusControllerDependency('clipboard')], $kit->getComponent('Clipboard')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('date-picker')], $kit->getComponent('DatePicker')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('local-time')], $kit->getComponent('LocalTime')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('users--list-item'), new StimulusControllerDependency('clipboard')], $kit->getComponent('UsersListItem')->getDependencies()); + } +} diff --git a/src/Toolkit/tests/Kit/KitTest.php b/src/Toolkit/tests/Kit/KitTest.php index 11c1df98a7d..f9294020e3b 100644 --- a/src/Toolkit/tests/Kit/KitTest.php +++ b/src/Toolkit/tests/Kit/KitTest.php @@ -12,7 +12,7 @@ namespace Symfony\UX\Toolkit\Tests\Kit; use PHPUnit\Framework\TestCase; -use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Asset\Component; use Symfony\UX\Toolkit\File\File; use Symfony\UX\Toolkit\File\FileType; use Symfony\UX\Toolkit\Kit\Kit; diff --git a/src/Toolkit/tests/Registry/GitHubRegistryTest.php b/src/Toolkit/tests/Registry/GitHubRegistryTest.php index f720e349523..cffc454e602 100644 --- a/src/Toolkit/tests/Registry/GitHubRegistryTest.php +++ b/src/Toolkit/tests/Registry/GitHubRegistryTest.php @@ -56,7 +56,7 @@ public function testCanGetKitFromGithub(): void }); $githubRegistry = new GitHubRegistry( - self::getContainer()->get('ux_toolkit.kit.factory'), + self::getContainer()->get('ux_toolkit.kit.kit_factory'), $this->filesystem, $httpClient, ); @@ -74,7 +74,7 @@ public function testCanGetKitFromGithub(): void public function testShouldThrowExceptionIfKitNotFound(): void { $githubRegistry = new GitHubRegistry( - self::getContainer()->get('ux_toolkit.kit.factory'), + self::getContainer()->get('ux_toolkit.kit.kit_factory'), $this->filesystem, new MockHttpClient(fn () => new MockResponse( 'Not found', diff --git a/src/Toolkit/tests/Registry/LocalRegistryTest.php b/src/Toolkit/tests/Registry/LocalRegistryTest.php index ca0bbd0fc76..b99cf8e8b70 100644 --- a/src/Toolkit/tests/Registry/LocalRegistryTest.php +++ b/src/Toolkit/tests/Registry/LocalRegistryTest.php @@ -20,7 +20,7 @@ final class LocalRegistryTest extends KernelTestCase public function testCanGetKit(): void { $localRegistry = new LocalRegistry( - self::getContainer()->get('ux_toolkit.kit.factory'), + self::getContainer()->get('ux_toolkit.kit.kit_factory'), self::getContainer()->get('filesystem'), self::getContainer()->getParameter('kernel.project_dir'), ); diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php index 52c0ccbc1e4..7bc4af68dd0 100644 --- a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -13,7 +13,7 @@ use App\Enum\ToolkitKitId; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Asset\Component; use Symfony\UX\Toolkit\Kit\Kit; use Symfony\UX\Toolkit\Registry\RegistryFactory; diff --git a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php index 92eef065e6a..7d99e218906 100644 --- a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\String\AbstractString; -use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Asset\Component; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Tempest\Highlight\Highlighter; From c5e763d129b9c57547715098aa4d9d4d8a0736ae Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 2 May 2025 10:12:19 +0200 Subject: [PATCH 36/58] [Toolkit] Update version handling in dependencies to use string format, improve command output messages, improve InstallComponentCommand (interactive ask), remove LintKitCommand --- src/Toolkit/bin/ux-toolkit-kit-debug | 10 +-- src/Toolkit/bin/ux-toolkit-kit-lint | 61 ---------------- src/Toolkit/composer.json | 1 - src/Toolkit/config/services.php | 13 +--- src/Toolkit/src/Command/DebugKitCommand.php | 30 ++++---- .../src/Command/InstallComponentCommand.php | 38 ++++++++-- src/Toolkit/src/Command/LintKitCommand.php | 73 ------------------- src/Toolkit/src/Dependency/Version.php | 16 ++-- src/Toolkit/src/Kit/KitSynchronizer.php | 2 +- src/Toolkit/tests/Asset/ComponentTest.php | 12 +-- .../tests/Command/DebugKitCommandTest.php | 2 +- .../Command/InstallComponentCommandTest.php | 2 +- .../tests/Command/LintKitCommandTest.php | 30 -------- .../Dependency/PhpPackageDependencyTest.php | 2 +- src/Toolkit/tests/Dependency/VersionTest.php | 17 ++--- src/Toolkit/tests/Fixtures/Kernel.php | 2 +- src/Toolkit/tests/Installer/PoolTest.php | 4 +- src/Toolkit/tests/Kit/KitSynchronizerTest.php | 2 +- .../tests/Registry/RegistryFactoryTest.php | 4 +- ux.symfony.com/config/services.yaml | 4 +- .../src/Service/Toolkit/ToolkitService.php | 2 +- 21 files changed, 79 insertions(+), 248 deletions(-) delete mode 100755 src/Toolkit/bin/ux-toolkit-kit-lint delete mode 100644 src/Toolkit/src/Command/LintKitCommand.php delete mode 100644 src/Toolkit/tests/Command/LintKitCommandTest.php diff --git a/src/Toolkit/bin/ux-toolkit-kit-debug b/src/Toolkit/bin/ux-toolkit-kit-debug index 91270bfa469..f10d9edc0f1 100755 --- a/src/Toolkit/bin/ux-toolkit-kit-debug +++ b/src/Toolkit/bin/ux-toolkit-kit-debug @@ -47,14 +47,8 @@ if (!class_exists(Application::class)) { $filesystem = new Filesystem(); $kitFactory = new KitFactory($filesystem, new KitSynchronizer($filesystem)); -$registryFactory = new RegistryFactory(new class([ - Type::Local->value => fn () => new LocalRegistry($kitFactory, $filesystem, getcwd() ?? throw new \RuntimeException('The current working directory could not be determined.')), - Type::GitHub->value => fn () => new GitHubRegistry($kitFactory, $filesystem, class_exists(HttpClient::class) ? HttpClient::create() : null), -]) implements ServiceProviderInterface { - use ServiceLocatorTrait; -}); - -(new Application())->add($command = new DebugKitCommand($registryFactory)) + +(new Application())->add($command = new DebugKitCommand($kitFactory)) ->getApplication() ->setDefaultCommand($command->getName(), true) ->run() diff --git a/src/Toolkit/bin/ux-toolkit-kit-lint b/src/Toolkit/bin/ux-toolkit-kit-lint deleted file mode 100755 index a7b5eb16c1e..00000000000 --- a/src/Toolkit/bin/ux-toolkit-kit-lint +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env php - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -if ('cli' !== \PHP_SAPI) { - throw new Exception('This script must be run from the command line.'); -} - -use Symfony\Component\Console\Application; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Contracts\Service\ServiceLocatorTrait; -use Symfony\Contracts\Service\ServiceProviderInterface; -use Symfony\UX\Toolkit\Command\LintKitCommand; -use Symfony\UX\Toolkit\Kit\KitSynchronizer; -use Symfony\UX\Toolkit\Kit\KitFactory; -use Symfony\UX\Toolkit\Registry\GitHubRegistry; -use Symfony\UX\Toolkit\Registry\LocalRegistry; -use Symfony\UX\Toolkit\Registry\RegistryFactory; -use Symfony\UX\Toolkit\Registry\Type; - -function includeIfExists(string $file): bool -{ - return file_exists($file) && include $file; -} - -if ( - !includeIfExists(__DIR__ . '/../../../autoload.php') && - !includeIfExists(__DIR__ . '/../vendor/autoload.php') -) { - fwrite(STDERR, 'Install dependencies using Composer.'.PHP_EOL); - exit(1); -} - -if (!class_exists(Application::class)) { - fwrite(STDERR, 'You need the "symfony/console" component in order to run the UX Toolkit kit linter.'.PHP_EOL); - exit(1); -} - -$filesystem = new Filesystem(); -$kitFactory = new KitFactory($filesystem, new KitSynchronizer($filesystem)); -$registryFactory = new RegistryFactory(new class([ - Type::Local->value => fn () => new LocalRegistry($kitFactory, $filesystem, getcwd() ?? throw new \RuntimeException('The current working directory could not be determined.')), - Type::GitHub->value => fn () => new GitHubRegistry($kitFactory, $filesystem, class_exists(HttpClient::class) ? HttpClient::create() : null), -]) implements ServiceProviderInterface { - use ServiceLocatorTrait; -}); - -(new Application())->add($command = new LintKitCommand($registryFactory)) - ->getApplication() - ->setDefaultCommand($command->getName(), true) - ->run() -; diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json index e767161609d..dc3a22674a2 100644 --- a/src/Toolkit/composer.json +++ b/src/Toolkit/composer.json @@ -49,7 +49,6 @@ }, "bin": [ "bin/ux-toolkit-kit-create", - "bin/ux-toolkit-kit-lint", "bin/ux-toolkit-kit-debug" ], "autoload": { diff --git a/src/Toolkit/config/services.php b/src/Toolkit/config/services.php index bc992d3a26c..1f108f10dfd 100644 --- a/src/Toolkit/config/services.php +++ b/src/Toolkit/config/services.php @@ -13,7 +13,6 @@ use Symfony\UX\Toolkit\Command\DebugKitCommand; use Symfony\UX\Toolkit\Command\InstallComponentCommand; -use Symfony\UX\Toolkit\Command\LintKitCommand; use Symfony\UX\Toolkit\Kit\KitFactory; use Symfony\UX\Toolkit\Kit\KitSynchronizer; use Symfony\UX\Toolkit\Registry\GitHubRegistry; @@ -30,27 +29,21 @@ ->set('.ux_toolkit.command.debug_kit', DebugKitCommand::class) ->args([ - service('.ux_toolkit.registry.factory'), + service('.ux_toolkit.kit.kit_factory'), ]) ->tag('console.command') ->set('.ux_toolkit.command.install', InstallComponentCommand::class) ->args([ param('ux_toolkit.kit'), - service('.ux_toolkit.registry.factory'), + service('.ux_toolkit.registry.registry_factory'), service('filesystem'), ]) ->tag('console.command') - ->set('.ux_toolkit.command.lint_kit', LintKitCommand::class) - ->args([ - service('.ux_toolkit.registry.factory'), - ]) - ->tag('console.command') - // Registry - ->set('.ux_toolkit.registry.factory', RegistryFactory::class) + ->set('.ux_toolkit.registry.registry_factory', RegistryFactory::class) ->args([ service_locator([ Type::Local->value => service('.ux_toolkit.registry.local'), diff --git a/src/Toolkit/src/Command/DebugKitCommand.php b/src/Toolkit/src/Command/DebugKitCommand.php index 1aece4c5807..e575874e4e3 100644 --- a/src/Toolkit/src/Command/DebugKitCommand.php +++ b/src/Toolkit/src/Command/DebugKitCommand.php @@ -19,7 +19,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\UX\Toolkit\Registry\RegistryFactory; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Kit\KitFactory; /** * @author Jean-François Lépine @@ -29,13 +30,13 @@ */ #[AsCommand( name: 'ux:toolkit:debug-kit', - description: 'Debug a kit, dump the dependencies.', + description: 'Debug a local Kit.', hidden: true, )] class DebugKitCommand extends Command { public function __construct( - private readonly RegistryFactory $registryFactory, + private readonly KitFactory $kitFactory, ) { parent::__construct(); } @@ -43,19 +44,14 @@ public function __construct( protected function configure(): void { $this - ->addArgument('kit', InputArgument::REQUIRED, 'The kit name, can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name").') + ->addArgument('kit-path', InputArgument::OPTIONAL, 'The path to the kit to debug', '.') ->setHelp(<<<'EOF' -The kit name can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name"). - -To debug a local kit: - - php %command.full_name% shadcn - -To debug a GitHub kit: - - php %command.full_name% https://github.com/user/repository@kit-name - php %command.full_name% https://github.com/user/repository@kit-name@v1.0.0 +To debug a Kit in the current directory: + php %command.full_name% +Or in another directory: + php %command.full_name% ./kits/shadcn + php %command.full_name% /path/to/my-kit EOF ); } @@ -64,9 +60,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $kitName = $input->getArgument('kit'); - $registry = $this->registryFactory->getForKit($kitName); - $kit = $registry->getKit($kitName); + $kitPath = $input->getArgument('kit-path'); + $kitPath = Path::makeAbsolute($kitPath, getcwd()); + $kit = $this->kitFactory->createKitFromAbsolutePath($kitPath); $io->title(\sprintf('Kit "%s"', $kit->name)); diff --git a/src/Toolkit/src/Command/InstallComponentCommand.php b/src/Toolkit/src/Command/InstallComponentCommand.php index 11a2ef8e498..2b716e617e7 100644 --- a/src/Toolkit/src/Command/InstallComponentCommand.php +++ b/src/Toolkit/src/Command/InstallComponentCommand.php @@ -34,7 +34,7 @@ */ #[AsCommand( name: 'ux:toolkit:install-component', - description: 'This command will install a new UX Component in your project', + description: 'Install a new UX Component (e.g. Alert) in your project', )] class InstallComponentCommand extends Command { @@ -52,7 +52,7 @@ public function __construct( protected function configure(): void { $this - ->addArgument('component', InputArgument::REQUIRED, 'The component name (Ex: Button)') + ->addArgument('component', InputArgument::OPTIONAL, 'The component name (Ex: Button)') ->addOption( 'destination', 'd', @@ -76,8 +76,8 @@ protected function configure(): void To install a component from an external GitHub kit, use the --kit option: -php %command.full_name% Button --kit=https://github.com/user/repository@kit -php %command.full_name% Button --kit=https://github.com/user/repository@kit:branch +php %command.full_name% Button --kit=https://github.com/user/my-kit +php %command.full_name% Button --kit=https://github.com/user/my-kit:branch EOF ); } @@ -95,8 +95,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $registry = $this->registryFactory->getForKit($kitName); $kit = $registry->getKit($kitName); - // Get the component name from the argument, or suggest alternatives if it doesn't exist - if (null === $component = $kit->getComponent($componentName = $input->getArgument('component'))) { + if (null === $componentName = $input->getArgument('component')) { + // Ask for the component name if not provided + $componentName = $io->choice('Which component do you want to install?', array_map(fn (Component $component) => $component->name, $this->getAvailableComponents($kit))); + $component = $kit->getComponent($componentName); + } elseif (null === $component = $kit->getComponent($componentName)) { + // Suggest alternatives if component does not exist $message = \sprintf('The component "%s" does not exist.', $componentName); $alternativeComponents = $this->getAlternativeComponents($kit, $componentName); @@ -120,7 +124,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $this->io->text(\sprintf('Installing component %s...', $component->name)); + $io->writeln(\sprintf('Installing component %s from the %s kit...', $component->name, $kitName)); $installer = new Installer($this->filesystem, fn (string $question) => $this->io->confirm($question, $input->isInteractive())); $installationReport = $installer->installComponent($kit, $component, $destinationPath = $input->getOption('destination'), $input->getOption('force')); @@ -144,7 +148,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * Get alternative components that are similar to the given component name. + * @return list + */ + private function getAvailableComponents(Kit $kit): array + { + $availableComponents = []; + + foreach ($kit->getComponents() as $component) { + if (str_contains($component->name, ':')) { + continue; + } + + $availableComponents[] = $component; + } + + return $availableComponents; + } + + /** + * @return list */ private function getAlternativeComponents(Kit $kit, string $componentName): array { diff --git a/src/Toolkit/src/Command/LintKitCommand.php b/src/Toolkit/src/Command/LintKitCommand.php deleted file mode 100644 index 65300d38d64..00000000000 --- a/src/Toolkit/src/Command/LintKitCommand.php +++ /dev/null @@ -1,73 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Command; - -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\UX\Toolkit\Registry\RegistryFactory; - -/** - * @author Jean-François Lépine - * @author Hugo Alliaume - * - * @internal - */ -#[AsCommand( - name: 'ux:toolkit:lint-kit', - description: 'Lint a kit, check for common mistakes and ensure the kit is valid.', - hidden: true, -)] -class LintKitCommand extends Command -{ - public function __construct( - private readonly RegistryFactory $registryFactory, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addArgument('kit', InputArgument::REQUIRED, 'The kit name, can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name").') - ->setHelp(<<<'EOF' -The kit name can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name"). - -To lint a local kit: - -php %command.full_name% shadcn - -To lint a GitHub kit: - -php %command.full_name% https://github.com/user/repository@kit-name -php %command.full_name% https://github.com/user/repository@kit-name@v1.0.0 - -EOF - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $kitName = $input->getArgument('kit'); - $registry = $this->registryFactory->getForKit($kitName); - $kit = $registry->getKit($kitName); - - $io->success(\sprintf('The kit "%s" is valid, it has %d components.', $kit->name, \count($kit->getComponents()))); - - return Command::SUCCESS; - } -} diff --git a/src/Toolkit/src/Dependency/Version.php b/src/Toolkit/src/Dependency/Version.php index ff94ebdb4fc..541d936b35e 100644 --- a/src/Toolkit/src/Dependency/Version.php +++ b/src/Toolkit/src/Dependency/Version.php @@ -12,7 +12,7 @@ namespace Symfony\UX\Toolkit\Dependency; /** - * Represents a version number, following a simplified version of the SemVer specification. + * Represents a version number, following the SemVer specification. * * @internal * @@ -21,26 +21,20 @@ final class Version implements \Stringable { /** - * @param int<0, max> $major - * @param int<0, max> $minor - * @param int<0, max> $patch + * @param non-empty-string */ public function __construct( - public readonly int $major, - public readonly int $minor, - public readonly int $patch, + public readonly string $value, ) { } public function isHigherThan(self $version): bool { - return $this->major > $version->major - || ($this->major === $version->major && $this->minor > $version->minor) - || ($this->major === $version->major && $this->minor === $version->minor && $this->patch > $version->patch); + return version_compare($this->value, $version->value, '>'); } public function __toString(): string { - return \sprintf('%d.%d.%d', $this->major, $this->minor, $this->patch); + return $this->value; } } diff --git a/src/Toolkit/src/Kit/KitSynchronizer.php b/src/Toolkit/src/Kit/KitSynchronizer.php index a3aa36d7091..853fe622429 100644 --- a/src/Toolkit/src/Kit/KitSynchronizer.php +++ b/src/Toolkit/src/Kit/KitSynchronizer.php @@ -106,7 +106,7 @@ private function resolveComponentDependencies(Kit $kit, Component $component): v if (FileType::Twig === $file->type) { if (str_contains($fileContent, 'html_cva')) { $component->addDependency(new PhpPackageDependency('twig/extra-bundle')); - $component->addDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0))); + $component->addDependency(new PhpPackageDependency('twig/html-extra', new Version('3.12.0'))); } if (str_contains($fileContent, 'tailwind_merge')) { diff --git a/src/Toolkit/tests/Asset/ComponentTest.php b/src/Toolkit/tests/Asset/ComponentTest.php index 27378fa1d1e..a1babf6b135 100644 --- a/src/Toolkit/tests/Asset/ComponentTest.php +++ b/src/Toolkit/tests/Asset/ComponentTest.php @@ -60,7 +60,7 @@ public function testCanAddAndGetDependencies(): void $component->addDependency($dependency1 = new ComponentDependency('Icon')); $component->addDependency($dependency2 = new ComponentDependency('Label')); - $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version(2, 24, 0))); + $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); self::assertCount(3, $component->getDependencies()); self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); @@ -75,7 +75,7 @@ public function testShouldNotAddDuplicateComponentDependencies(): void $component->addDependency($dependency1 = new ComponentDependency('Icon')); $component->addDependency($dependency2 = new ComponentDependency('Label')); $component->addDependency($dependency3 = new ComponentDependency('Icon')); - $component->addDependency($dependency4 = new PhpPackageDependency('symfony/twig-component', new Version(2, 24, 0))); + $component->addDependency($dependency4 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); self::assertCount(3, $component->getDependencies()); self::assertEquals([$dependency1, $dependency2, $dependency4], $component->getDependencies()); @@ -89,12 +89,12 @@ public function testShouldReplacePhpPackageDependencyIfVersionIsHigher(): void $component->addDependency($dependency1 = new ComponentDependency('Icon')); $component->addDependency($dependency2 = new ComponentDependency('Label')); - $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version(2, 24, 0))); + $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); self::assertCount(3, $component->getDependencies()); self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); - $component->addDependency($dependency4 = new PhpPackageDependency('symfony/twig-component', new Version(2, 25, 0))); + $component->addDependency($dependency4 = new PhpPackageDependency('symfony/twig-component', new Version('2.25.0'))); self::assertCount(3, $component->getDependencies()); self::assertEquals([$dependency1, $dependency2, $dependency4], $component->getDependencies()); @@ -108,12 +108,12 @@ public function testShouldNotReplacePhpPackageDependencyIfVersionIsLower(): void $component->addDependency($dependency1 = new ComponentDependency('Icon')); $component->addDependency($dependency2 = new ComponentDependency('Label')); - $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version(2, 24, 0))); + $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); self::assertCount(3, $component->getDependencies()); self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); - $component->addDependency(new PhpPackageDependency('symfony/twig-component', new Version(2, 23, 0))); + $component->addDependency(new PhpPackageDependency('symfony/twig-component', new Version('2.23.0'))); self::assertCount(3, $component->getDependencies()); self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); diff --git a/src/Toolkit/tests/Command/DebugKitCommandTest.php b/src/Toolkit/tests/Command/DebugKitCommandTest.php index 5933a32f9e9..4704b55df1d 100644 --- a/src/Toolkit/tests/Command/DebugKitCommandTest.php +++ b/src/Toolkit/tests/Command/DebugKitCommandTest.php @@ -21,7 +21,7 @@ class DebugKitCommandTest extends KernelTestCase public function testShouldBeAbleToDebug(): void { $this->bootKernel(); - $this->consoleCommand('ux:toolkit:debug-kit shadcn') + $this->consoleCommand(\sprintf('ux:toolkit:debug-kit %s', __DIR__.'/../../kits/shadcn')) ->execute() ->assertSuccessful() // Kit details diff --git a/src/Toolkit/tests/Command/InstallComponentCommandTest.php b/src/Toolkit/tests/Command/InstallComponentCommandTest.php index a0d0b16d928..aeb2c1e36c8 100644 --- a/src/Toolkit/tests/Command/InstallComponentCommandTest.php +++ b/src/Toolkit/tests/Command/InstallComponentCommandTest.php @@ -53,7 +53,7 @@ public function testShouldAbleToInstallComponentTableAndItsDependencies(): void $testCommand = $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) ->execute() ->assertSuccessful() - ->assertOutputContains('Installing component Table...') + ->assertOutputContains('Installing component Table from the shadcn kit...') ->assertOutputContains('[OK] The component has been installed.') ; diff --git a/src/Toolkit/tests/Command/LintKitCommandTest.php b/src/Toolkit/tests/Command/LintKitCommandTest.php deleted file mode 100644 index cdc94705b58..00000000000 --- a/src/Toolkit/tests/Command/LintKitCommandTest.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Toolkit\Tests\Command; - -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Zenstruck\Console\Test\InteractsWithConsole; - -class LintKitCommandTest extends KernelTestCase -{ - use InteractsWithConsole; - - public function testShouldBeAbleToLint(): void - { - $this->bootKernel(); - $this->consoleCommand('ux:toolkit:lint-kit shadcn') - ->execute() - ->assertSuccessful() - ->assertOutputContains('The kit "Shadcn UI" is valid, it has 46 components') - ; - } -} diff --git a/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php index b4fb1569230..8efb9f75501 100644 --- a/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php +++ b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php @@ -24,7 +24,7 @@ public function testShouldBeInstantiable(): void $this->assertNull($dependency->constraintVersion); $this->assertSame('twig/html-extra', (string) $dependency); - $dependency = new PhpPackageDependency('twig/html-extra', new Version(3, 2, 1)); + $dependency = new PhpPackageDependency('twig/html-extra', new Version('3.2.1')); $this->assertSame('twig/html-extra', $dependency->name); $this->assertSame('twig/html-extra:^3.2.1', (string) $dependency); } diff --git a/src/Toolkit/tests/Dependency/VersionTest.php b/src/Toolkit/tests/Dependency/VersionTest.php index 3189047ba6b..cc0c50f52ca 100644 --- a/src/Toolkit/tests/Dependency/VersionTest.php +++ b/src/Toolkit/tests/Dependency/VersionTest.php @@ -18,21 +18,18 @@ final class VersionTest extends TestCase { public function testCanBeInstantiated(): void { - $version = new Version(1, 2, 3); + $version = new Version('1.2.3'); - $this->assertSame(1, $version->major); - $this->assertSame(2, $version->minor); - $this->assertSame(3, $version->patch); $this->assertSame('1.2.3', (string) $version); } public function testCanBeCompared(): void { - $this->assertTrue((new Version(1, 2, 3))->isHigherThan(new Version(1, 2, 2))); - $this->assertFalse((new Version(1, 2, 3))->isHigherThan(new Version(1, 2, 4))); - $this->assertTrue((new Version(1, 2, 3))->isHigherThan(new Version(1, 1, 99))); - $this->assertFalse((new Version(1, 2, 3))->isHigherThan(new Version(1, 2, 3))); - $this->assertTrue((new Version(1, 2, 3))->isHigherThan(new Version(0, 99, 99))); - $this->assertFalse((new Version(1, 2, 3))->isHigherThan(new Version(2, 0, 0))); + $this->assertTrue((new Version('1.2.3'))->isHigherThan(new Version('1.2.2'))); + $this->assertFalse((new Version('1.2.3'))->isHigherThan(new Version('1.2.4'))); + $this->assertTrue((new Version('1.2.3'))->isHigherThan(new Version('1.1.99'))); + $this->assertFalse((new Version('1.2.3'))->isHigherThan(new Version('1.2.3'))); + $this->assertTrue((new Version('1.2.3'))->isHigherThan(new Version('0.99.99'))); + $this->assertFalse((new Version('1.2.3'))->isHigherThan(new Version('2.0.0'))); } } diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php index 7b9b95ff415..d1853b06649 100644 --- a/src/Toolkit/tests/Fixtures/Kernel.php +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -63,7 +63,7 @@ protected function configureContainer(ContainerConfigurator $container): void ->alias('ux_toolkit.kit.kit_synchronizer', '.ux_toolkit.kit.kit_synchronizer') ->public() - ->alias('ux_toolkit.registry.factory', '.ux_toolkit.registry.factory') + ->alias('ux_toolkit.registry.registry_factory', '.ux_toolkit.registry.registry_factory') ->public() ; } diff --git a/src/Toolkit/tests/Installer/PoolTest.php b/src/Toolkit/tests/Installer/PoolTest.php index 3e52a8fdadc..027f2433c8f 100644 --- a/src/Toolkit/tests/Installer/PoolTest.php +++ b/src/Toolkit/tests/Installer/PoolTest.php @@ -67,12 +67,12 @@ public function testCanAddPhpPackageDependencyWithHigherVersion(): void { $pool = new Pool(); - $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 11, 0))); + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version('3.11.0'))); $this->assertCount(1, $pool->getPhpPackageDependencies()); $this->assertEquals('twig/html-extra:^3.11.0', (string) $pool->getPhpPackageDependencies()['twig/html-extra']); - $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0))); + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version('3.12.0'))); $this->assertCount(1, $pool->getPhpPackageDependencies()); $this->assertEquals('twig/html-extra:^3.12.0', (string) $pool->getPhpPackageDependencies()['twig/html-extra']); diff --git a/src/Toolkit/tests/Kit/KitSynchronizerTest.php b/src/Toolkit/tests/Kit/KitSynchronizerTest.php index cb6e51f3742..3b16710fd06 100644 --- a/src/Toolkit/tests/Kit/KitSynchronizerTest.php +++ b/src/Toolkit/tests/Kit/KitSynchronizerTest.php @@ -42,7 +42,7 @@ public function testCanResolveDependencies(): void $this->assertEquals([ new PhpPackageDependency('twig/extra-bundle'), - new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0)), + new PhpPackageDependency('twig/html-extra', new Version('3.12.0')), new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), ], $kit->getComponent('Button')->getDependencies()); diff --git a/src/Toolkit/tests/Registry/RegistryFactoryTest.php b/src/Toolkit/tests/Registry/RegistryFactoryTest.php index 24f11395206..71ad4c4bf38 100644 --- a/src/Toolkit/tests/Registry/RegistryFactoryTest.php +++ b/src/Toolkit/tests/Registry/RegistryFactoryTest.php @@ -36,7 +36,7 @@ public static function provideRegistryNames(): array */ public function testCanCreateRegistry(string $registryName, string $expectedRegistryClass): void { - $registryFactory = self::getContainer()->get('ux_toolkit.registry.factory'); + $registryFactory = self::getContainer()->get('ux_toolkit.registry.registry_factory'); $registry = $registryFactory->getForKit($registryName); @@ -58,7 +58,7 @@ public static function provideInvalidRegistryNames(): array */ public function testShouldFailIfRegistryIsNotFound(string $registryName): void { - $registryFactory = self::getContainer()->get('ux_toolkit.registry.factory'); + $registryFactory = self::getContainer()->get('ux_toolkit.registry.registry_factory'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage(\sprintf('The kit "%s" is not valid.', $registryName)); diff --git a/ux.symfony.com/config/services.yaml b/ux.symfony.com/config/services.yaml index 38d4b5dd9a7..16090c23197 100644 --- a/ux.symfony.com/config/services.yaml +++ b/ux.symfony.com/config/services.yaml @@ -25,5 +25,5 @@ services: Tempest\Highlight\Highlighter: tags: ['twig.runtime'] - ux_toolkit.registry.factory: - alias: '.ux_toolkit.registry.factory' + ux_toolkit.registry.registry_factory: + alias: '.ux_toolkit.registry.registry_factory' diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php index 7bc4af68dd0..ef4807d7bb8 100644 --- a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -20,7 +20,7 @@ class ToolkitService { public function __construct( - #[Autowire(service: 'ux_toolkit.registry.factory')] + #[Autowire(service: 'ux_toolkit.registry.registry_factory')] private RegistryFactory $registryFactory, ) { } From d648b2ca5c1dfe6e796c4d8ffd60ebec35b7db71 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 2 May 2025 10:29:32 +0200 Subject: [PATCH 37/58] [Toolkit] Do not use FileSystem::readFile(), as it has been implemented in Symfony 7.1 and we want to support at least Symfony 6.4 --- src/Toolkit/src/Kit/KitFactory.php | 2 +- src/Toolkit/src/Kit/KitSynchronizer.php | 10 +++++++--- src/Toolkit/tests/Installer/InstallerTest.php | 10 +++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php index 88fc17a2f7d..6a1f5432b22 100644 --- a/src/Toolkit/src/Kit/KitFactory.php +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -45,7 +45,7 @@ public function createKitFromAbsolutePath(string $absolutePath): Kit throw new \InvalidArgumentException(\sprintf('File "%s" not found.', $manifestPath)); } - $manifest = json_decode($this->filesystem->readFile($manifestPath), true, flags: \JSON_THROW_ON_ERROR); + $manifest = json_decode(file_get_contents($manifestPath), true, flags: \JSON_THROW_ON_ERROR); $kit = new Kit( path: $absolutePath, diff --git a/src/Toolkit/src/Kit/KitSynchronizer.php b/src/Toolkit/src/Kit/KitSynchronizer.php index 853fe622429..e5907ae2618 100644 --- a/src/Toolkit/src/Kit/KitSynchronizer.php +++ b/src/Toolkit/src/Kit/KitSynchronizer.php @@ -101,7 +101,11 @@ private function resolveComponentDependencies(Kit $kit, Component $component): v // Find dependencies based on file content foreach ($component->files as $file) { - $fileContent = $this->filesystem->readFile(Path::join($kit->path, $file->relativePathNameToKit)); + if (!$this->filesystem->exists($filePath = Path::join($kit->path, $file->relativePathNameToKit))) { + throw new \RuntimeException(\sprintf('File "%s" not found', $filePath)); + } + + $fileContent = file_get_contents($filePath); if (FileType::Twig === $file->type) { if (str_contains($fileContent, 'html_cva')) { @@ -174,14 +178,14 @@ private function synchronizeDocumentation(Kit $kit): void // Read INSTALL.md if exists $fileInstall = Path::join($kit->path, 'INSTALL.md'); if ($this->filesystem->exists($fileInstall)) { - $kit->installAsMarkdown = $this->filesystem->readFile($fileInstall); + $kit->installAsMarkdown = file_get_contents($fileInstall); } // Iterate over Component and find their documentation foreach ($kit->getComponents() as $component) { $docPath = Path::join($kit->path, 'docs', 'components', $component->name.'.md'); if ($this->filesystem->exists($docPath)) { - $component->doc = new Doc($this->filesystem->readFile($docPath)); + $component->doc = new Doc(file_get_contents($docPath)); } } } diff --git a/src/Toolkit/tests/Installer/InstallerTest.php b/src/Toolkit/tests/Installer/InstallerTest.php index 554ebbbd74e..7386d581f63 100644 --- a/src/Toolkit/tests/Installer/InstallerTest.php +++ b/src/Toolkit/tests/Installer/InstallerTest.php @@ -46,7 +46,7 @@ public function testCanInstallComponent(): void $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); - $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); } public function testShouldAskIfFileAlreadyExists(): void @@ -66,7 +66,7 @@ public function testShouldAskIfFileAlreadyExists(): void $this->assertSame(0, $askedCount); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); - $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); $this->assertSame(1, $askedCount); @@ -83,12 +83,12 @@ public function testCanInstallComponentIfForced(): void $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); - $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); $componentInstaller->installComponent($kit, $component, $this->tmpDir, true); $this->assertFileExists($this->tmpDir.'/Button.html.twig'); - $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); } public function testCanInstallComponentAndItsComponentDependencies(): void @@ -119,7 +119,7 @@ public function testCanInstallComponentAndItsComponentDependencies(): void foreach ($expectedFiles as $fileName => $expectedFile) { $this->assertFileExists($expectedFile); - $this->assertSame($this->filesystem->readFile($expectedFile), $this->filesystem->readFile(\sprintf('%s/templates/components/%s', $kit->path, $fileName))); + $this->assertSame(file_get_contents($expectedFile), file_get_contents(\sprintf('%s/templates/components/%s', $kit->path, $fileName))); } } From 134e1eb38747b4ae1cc6d6f72b98f8c78ece4752 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sat, 3 May 2025 09:00:21 +0200 Subject: [PATCH 38/58] [Toolkit] Improve InstallComponentCommand by asking/guessing which Kit to use, remove ux_toolkit.kit parameter, remove DependencyInjection configuration --- src/Toolkit/config/services.php | 2 - src/Toolkit/doc/index.rst | 42 +++++----------- .../src/Command/InstallComponentCommand.php | 50 ++++++++++++++++--- src/Toolkit/src/Registry/LocalRegistry.php | 36 ++++++++----- src/Toolkit/src/UXToolkitBundle.php | 23 --------- .../Command/InstallComponentCommandTest.php | 10 ++-- src/Toolkit/tests/UXToolkitBundleTest.php | 1 - .../config/packages/ux_toolkit.yaml | 2 - .../Twig/Components/Toolkit/ComponentDoc.php | 5 +- 9 files changed, 87 insertions(+), 84 deletions(-) delete mode 100644 ux.symfony.com/config/packages/ux_toolkit.yaml diff --git a/src/Toolkit/config/services.php b/src/Toolkit/config/services.php index 1f108f10dfd..23a6e5289c3 100644 --- a/src/Toolkit/config/services.php +++ b/src/Toolkit/config/services.php @@ -35,7 +35,6 @@ ->set('.ux_toolkit.command.install', InstallComponentCommand::class) ->args([ - param('ux_toolkit.kit'), service('.ux_toolkit.registry.registry_factory'), service('filesystem'), ]) @@ -55,7 +54,6 @@ ->args([ service('.ux_toolkit.kit.kit_factory'), service('filesystem'), - param('kernel.project_dir'), ]) ->set('.ux_toolkit.registry.github', GitHubRegistry::class) diff --git a/src/Toolkit/doc/index.rst b/src/Toolkit/doc/index.rst index b2d0aad554d..556ef1e669b 100644 --- a/src/Toolkit/doc/index.rst +++ b/src/Toolkit/doc/index.rst @@ -17,8 +17,8 @@ It uses the same approach than the popular `Shadcn UI`_, and a similar approach than `Tailwind Plus`_. After installing the UX Toolkit, you can start pulling the components you need -from the `UX Components page`_, and use them in your project. -They become your own components, and you can customize them as you want. +from `UX Toolkit Kits`_, and use them in your project. +They become **your own components**, and **you can customize them as you want**. Additionally, some `Twig components`_ use ``html_cva`` and ``tailwind_merge``, you can either remove them from your project or install ``twig/html-extra`` @@ -40,27 +40,16 @@ Install the UX Toolkit using Composer and Symfony Flex: # If you want to keep `html_cva` and `tailwind_merge` in your Twig components: $ composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra -Configuration -------------- - -Configuration is done in your ``config/packages/ux_toolkit.yaml`` file: - -.. code-block:: yaml - - # config/packages/ux_toolkit.yaml - ux_toolkit: - kit: 'shadcn' - Usage ----- You may find a list of components in the `UX Components page`_, with the installation instructions for each of them. -For example, if you want to install the `Button` component, you will find the following instruction: +For example, if you want to install a `Button` component, you will find the following instruction: .. code-block:: terminal - $ php bin/console ux:toolkit:install-component Button + $ php bin/console ux:toolkit:install-component Button --kit= It will create the ``templates/components/Button.html.twig`` file, and you will be able to use the `Button` component like this: @@ -121,24 +110,17 @@ A kit is composed of: - A ``templates/components`` directory, that contains the Twig components, - A ``docs/components`` directory, optional, that contains the documentation for each "root" Twig component. -Use your kit in a Symfony application -------------------------------------- +Using your kit +~~~~~~~~~~~~~~ -You can globally configure the kit to use in your application by setting the ``ux_toolkit.kit`` configuration: - -.. code-block:: yaml - - # config/packages/ux_toolkit.yaml - ux_toolkit: - kit: 'github.com/my-username/my-ux-kits' - # or for a specific version - kit: 'github.com/my-username/my-ux-kits:1.0.0' - -If you do not want to globally configure the kit, you can pass the ``--kit`` option to the ``ux:toolkit:install-component`` command: +Once your kit is published on GitHub, you can use it by specifying the ``--kit`` option when installing a component: .. code-block:: terminal - $ php bin/console ux:toolkit:install-component Button --kit=github.com/my-username/my-ux-kits + $ php bin/console ux:toolkit:install-component Button --kit=github.com/my-username/my-ux-toolkit-kit + + # or for a specific version + $ php bin/console ux:toolkit:install-component Button --kit=github.com/my-username/my-ux-toolkit-kit:1.0.0 Backward Compatibility promise ------------------------------ @@ -153,6 +135,6 @@ We may break them in patch or minor release, but you won't get impacted unless y .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _`Twig components`: https://symfony.com/bundles/ux-twig-component/current/index.html -.. _`UX Components page`: https://ux.symfony.com/components +.. _`UX Toolkit Kits`: https://ux.symfony.com/toolkit#kits .. _`Shadcn UI`: https://ui.shadcn.com/ .. _`Tailwind Plus`: https://tailwindcss.com/plus diff --git a/src/Toolkit/src/Command/InstallComponentCommand.php b/src/Toolkit/src/Command/InstallComponentCommand.php index 2b716e617e7..83dd56eebf9 100644 --- a/src/Toolkit/src/Command/InstallComponentCommand.php +++ b/src/Toolkit/src/Command/InstallComponentCommand.php @@ -24,6 +24,7 @@ use Symfony\UX\Toolkit\File\File; use Symfony\UX\Toolkit\Installer\Installer; use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\LocalRegistry; use Symfony\UX\Toolkit\Registry\RegistryFactory; /** @@ -42,7 +43,6 @@ class InstallComponentCommand extends Command private bool $isInteractive; public function __construct( - private readonly string $kitName, private readonly RegistryFactory $registryFactory, private readonly Filesystem $filesystem, ) { @@ -53,6 +53,7 @@ protected function configure(): void { $this ->addArgument('component', InputArgument::OPTIONAL, 'The component name (Ex: Button)') + ->addOption('kit', 'k', InputOption::VALUE_OPTIONAL, 'The kit name (Ex: shadcn, or github.com/user/my-ux-toolkit-kit)') ->addOption( 'destination', 'd', @@ -61,7 +62,6 @@ protected function configure(): void Path::join('templates', 'components') ) ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the component installation, even if the component already exists') - ->addOption('kit', 'k', InputOption::VALUE_OPTIONAL, 'Override the kit name', $this->kitName) ->setHelp( <<%command.name% command will install a new UX Component in your project. @@ -92,10 +92,48 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $kitName = $input->getOption('kit'); - $registry = $this->registryFactory->getForKit($kitName); - $kit = $registry->getKit($kitName); + $componentName = $input->getArgument('component'); + + // If the kit name is not explicitly provided, we need to suggest one + if (null === $kitName) { + /** @var list $availableKits */ + $availableKits = []; + $availableKitNames = LocalRegistry::getAvailableKitsName(); + foreach ($availableKitNames as $availableKitName) { + $kit = $this->registryFactory->getForKit($availableKitName)->getKit($availableKitName); + + if (null === $componentName) { + $availableKits[] = $kit; + } elseif (null !== $kit->getComponent($componentName)) { + $availableKits[] = $kit; + } + } + // If more than one kit is available, we ask the user which one to use + if (($availableKitsCount = \count($availableKits)) > 1) { + $kitName = $io->choice(null === $componentName ? 'Which kit do you want to use?' : \sprintf('The component "%s" exists in multiple kits. Which one do you want to use?', $componentName), array_map(fn (Kit $kit) => $kit->name, $availableKits)); + + foreach ($availableKits as $availableKit) { + if ($availableKit->name === $kitName) { + $kit = $availableKit; + break; + } + } + } elseif (1 === $availableKitsCount) { + $kit = $availableKits[0]; + } else { + $io->error(null === $componentName + ? 'It seems that no local kits are available and it should not happens. Please open an issue on https://github.com/symfony/ux to report this.' + : sprintf("The component \"%s\" does not exist in any local kits.\n\nYou can try to run one of the following commands to interactively install components:\n%s\n\nOr you can try one of the community kits https://github.com/search?q=topic:ux-toolkit&type=repositories", $componentName, implode("\n", array_map(fn (string $availableKitName) => sprintf('$ bin/console %s --kit %s', $this->getName(), $availableKitName), $availableKitNames))) + ); + + return Command::FAILURE; + } + } else { + $registry = $this->registryFactory->getForKit($kitName); + $kit = $registry->getKit($kitName); + } - if (null === $componentName = $input->getArgument('component')) { + if (null === $componentName) { // Ask for the component name if not provided $componentName = $io->choice('Which component do you want to install?', array_map(fn (Component $component) => $component->name, $this->getAvailableComponents($kit))); $component = $kit->getComponent($componentName); @@ -124,7 +162,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $io->writeln(\sprintf('Installing component %s from the %s kit...', $component->name, $kitName)); + $io->writeln(\sprintf('Installing component %s from the %s kit...', $component->name, $kit->name)); $installer = new Installer($this->filesystem, fn (string $question) => $this->io->confirm($question, $input->isInteractive())); $installationReport = $installer->installComponent($kit, $component, $destinationPath = $input->getOption('destination'), $input->getOption('force')); diff --git a/src/Toolkit/src/Registry/LocalRegistry.php b/src/Toolkit/src/Registry/LocalRegistry.php index d7f849fa9b3..2cdb9b6e4a7 100644 --- a/src/Toolkit/src/Registry/LocalRegistry.php +++ b/src/Toolkit/src/Registry/LocalRegistry.php @@ -13,6 +13,7 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; use Symfony\UX\Toolkit\Kit\Kit; use Symfony\UX\Toolkit\Kit\KitFactory; @@ -24,6 +25,8 @@ */ final class LocalRegistry implements Registry { + private static string $kitsDir = __DIR__.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'kits'; + public static function supports(string $kitName): bool { return 1 === preg_match('/^[a-zA-Z0-9_-]+$/', $kitName); @@ -32,25 +35,34 @@ public static function supports(string $kitName): bool public function __construct( private readonly KitFactory $kitFactory, private readonly Filesystem $filesystem, - private readonly string $projectDir, ) { } public function getKit(string $kitName): Kit { - $possibleKitDirs = [ - // Local kit - Path::join($this->projectDir, 'kits', $kitName), - // From vendor - Path::join($this->projectDir, 'vendor', 'symfony', 'ux-toolkit', 'kits', $kitName), - ]; - - foreach ($possibleKitDirs as $kitDir) { - if ($this->filesystem->exists($kitDir)) { - return $this->kitFactory->createKitFromAbsolutePath($kitDir); - } + $kitDir = Path::join(self::$kitsDir, $kitName); + if ($this->filesystem->exists($kitDir)) { + return $this->kitFactory->createKitFromAbsolutePath($kitDir); } throw new \RuntimeException(\sprintf('Unable to find the kit "%s" in the following directories: "%s"', $kitName, implode('", "', $possibleKitDirs))); } + + /** + * @return array + */ + public static function getAvailableKitsName(): array + { + $availableKitsName = []; + $finder = (new Finder())->directories()->in(self::$kitsDir)->depth(0); + + foreach ($finder as $directory) { + $kitName = $directory->getRelativePathname(); + if (self::supports($kitName)) { + $availableKitsName[] = $kitName; + } + } + + return $availableKitsName; + } } diff --git a/src/Toolkit/src/UXToolkitBundle.php b/src/Toolkit/src/UXToolkitBundle.php index 317ee766081..0e38f2a251d 100644 --- a/src/Toolkit/src/UXToolkitBundle.php +++ b/src/Toolkit/src/UXToolkitBundle.php @@ -11,7 +11,6 @@ namespace Symfony\UX\Toolkit; -use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; @@ -24,30 +23,8 @@ class UXToolkitBundle extends AbstractBundle { protected string $extensionAlias = 'ux_toolkit'; - public function configure(DefinitionConfigurator $definition): void - { - $rootNode = $definition->rootNode(); - $rootNode - ->children() - ->scalarNode('kit') - ->info('The kit to use, it can be from the official UX Toolkit repository, or an external GitHub repository') - ->defaultValue('shadcn') - ->example([ - 'shadcn', - 'github.com/user/repository@my-kit', - 'github.com/user/repository@my-kit:main', - 'https://github.com/user/repository@my-kit', - ]) - ->end() - ->end(); - } - public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { - $container->parameters() - ->set('ux_toolkit.kit', $config['kit']) - ; - $container->import('../config/services.php'); } } diff --git a/src/Toolkit/tests/Command/InstallComponentCommandTest.php b/src/Toolkit/tests/Command/InstallComponentCommandTest.php index aeb2c1e36c8..79e7e811c50 100644 --- a/src/Toolkit/tests/Command/InstallComponentCommandTest.php +++ b/src/Toolkit/tests/Command/InstallComponentCommandTest.php @@ -53,7 +53,7 @@ public function testShouldAbleToInstallComponentTableAndItsDependencies(): void $testCommand = $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) ->execute() ->assertSuccessful() - ->assertOutputContains('Installing component Table from the shadcn kit...') + ->assertOutputContains('Installing component Table from the Shadcn UI kit...') ->assertOutputContains('[OK] The component has been installed.') ; @@ -65,16 +65,16 @@ public function testShouldAbleToInstallComponentTableAndItsDependencies(): void } } - public function testShouldFailAndSuggestAlternativeComponents(): void + public function testShouldFailAndSuggestAlternativeComponentsWhenKitIsExplicit(): void { $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); mkdir($destination); $this->bootKernel(); - $this->consoleCommand('ux:toolkit:install-component Table: --destination='.$destination) + $this->consoleCommand('ux:toolkit:install-component Table: --kit=shadcn --destination='.$destination) ->execute() ->assertFaulty() - ->assertOutputContains('[WARNING] The component "Table:" does not exist.') + ->assertOutputContains('[WARNING] The component "Table:" does not exist') ->assertOutputContains('Possible alternatives: ') ->assertOutputContains('"Table:Body"') ->assertOutputContains('"Table:Caption"') @@ -95,7 +95,7 @@ public function testShouldFailWhenComponentDoesNotExist(): void $this->consoleCommand('ux:toolkit:install-component Unknown --destination='.$destination) ->execute() ->assertFaulty() - ->assertOutputContains('The component "Unknown" does not exist.'); + ->assertOutputContains('The component "Unknown" does not exist'); } public function testShouldWarnWhenComponentFileAlreadyExistsInNonInteractiveMode(): void diff --git a/src/Toolkit/tests/UXToolkitBundleTest.php b/src/Toolkit/tests/UXToolkitBundleTest.php index 9c000930e64..f7b19becb83 100644 --- a/src/Toolkit/tests/UXToolkitBundleTest.php +++ b/src/Toolkit/tests/UXToolkitBundleTest.php @@ -22,6 +22,5 @@ public function testBundleBuildsSuccessfully(): void $container = self::$kernel->getContainer(); $this->assertInstanceOf(UXToolkitBundle::class, $container->get('kernel')->getBundles()['UXToolkitBundle']); - $this->assertEquals('shadcn', $container->getParameter('ux_toolkit.kit')); } } diff --git a/ux.symfony.com/config/packages/ux_toolkit.yaml b/ux.symfony.com/config/packages/ux_toolkit.yaml deleted file mode 100644 index 92e23b6fd30..00000000000 --- a/ux.symfony.com/config/packages/ux_toolkit.yaml +++ /dev/null @@ -1,2 +0,0 @@ -ux_toolkit: - kit: shadcn diff --git a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php index 7d99e218906..d3a003f5f7c 100644 --- a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -55,12 +55,11 @@ private function formatContent(string $markdownContent): string private function insertInstallation(AbstractString $markdownContent): AbstractString { $installationCode = SourceCleaner::processTerminalLines(<<component->name} -# or if you already use another kit -symfony console ux:toolkit:install-component {$this->component->name} --kit {$this->kitId->value} +bin/console ux:toolkit:install-component {$this->component->name} --kit {$this->kitId->value} SHELL ); + // TODO: Provide tabs showing automatic and manual installation return $markdownContent->replace( '', << Date: Tue, 6 May 2025 13:28:20 +0900 Subject: [PATCH 39/58] [StimulusBundle] Make the JS package private (for real) --- src/StimulusBundle/assets/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StimulusBundle/assets/package.json b/src/StimulusBundle/assets/package.json index 544fb35703b..d4f288fa1a0 100644 --- a/src/StimulusBundle/assets/package.json +++ b/src/StimulusBundle/assets/package.json @@ -1,7 +1,7 @@ { "name": "@symfony/stimulus-bundle", "description": "Integration of @hotwired/stimulus into Symfony", - "private": "true", + "private": true, "license": "MIT", "version": "2.24.0", "keywords": [ From 98dec2a0572d7b6b3b6a086c929d8757f8c0abde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Tue, 6 May 2025 22:04:26 +0200 Subject: [PATCH 40/58] [ToolKit] Add missing Interface suffixes Fix #2539 --- src/Toolkit/src/Asset/Component.php | 6 +++--- src/Toolkit/src/Dependency/ComponentDependency.php | 2 +- .../Dependency/{Dependency.php => DependencyInterface.php} | 2 +- src/Toolkit/src/Dependency/PhpPackageDependency.php | 2 +- src/Toolkit/src/Dependency/StimulusControllerDependency.php | 2 +- src/Toolkit/src/Registry/GitHubRegistry.php | 2 +- src/Toolkit/src/Registry/LocalRegistry.php | 2 +- src/Toolkit/src/Registry/RegistryFactory.php | 2 +- .../src/Registry/{Registry.php => RegistryInterface.php} | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) rename src/Toolkit/src/Dependency/{Dependency.php => DependencyInterface.php} (88%) rename src/Toolkit/src/Registry/{Registry.php => RegistryInterface.php} (95%) diff --git a/src/Toolkit/src/Asset/Component.php b/src/Toolkit/src/Asset/Component.php index 0b0baf2b037..09dacbdf7d9 100644 --- a/src/Toolkit/src/Asset/Component.php +++ b/src/Toolkit/src/Asset/Component.php @@ -13,7 +13,7 @@ use Symfony\UX\Toolkit\Assert; use Symfony\UX\Toolkit\Dependency\ComponentDependency; -use Symfony\UX\Toolkit\Dependency\Dependency; +use Symfony\UX\Toolkit\Dependency\DependencyInterface; use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; use Symfony\UX\Toolkit\File\Doc; @@ -43,7 +43,7 @@ public function __construct( } } - public function addDependency(Dependency $dependency): void + public function addDependency(DependencyInterface $dependency): void { foreach ($this->dependencies as $i => $existingDependency) { if ($existingDependency instanceof PhpPackageDependency && $existingDependency->name === $dependency->name) { @@ -69,7 +69,7 @@ public function addDependency(Dependency $dependency): void } /** - * @return list + * @return list */ public function getDependencies(): array { diff --git a/src/Toolkit/src/Dependency/ComponentDependency.php b/src/Toolkit/src/Dependency/ComponentDependency.php index ae9e231c0f3..7811c3ae34c 100644 --- a/src/Toolkit/src/Dependency/ComponentDependency.php +++ b/src/Toolkit/src/Dependency/ComponentDependency.php @@ -20,7 +20,7 @@ * * @author Hugo Alliaume */ -final class ComponentDependency implements Dependency +final class ComponentDependency implements DependencyInterface { /** * @param non-empty-string $name The name of the component, e.g. "Table" or "Table:Body" diff --git a/src/Toolkit/src/Dependency/Dependency.php b/src/Toolkit/src/Dependency/DependencyInterface.php similarity index 88% rename from src/Toolkit/src/Dependency/Dependency.php rename to src/Toolkit/src/Dependency/DependencyInterface.php index 4f6312fb39e..60957d99ce7 100644 --- a/src/Toolkit/src/Dependency/Dependency.php +++ b/src/Toolkit/src/Dependency/DependencyInterface.php @@ -18,6 +18,6 @@ * * @author Hugo Alliaume */ -interface Dependency extends \Stringable +interface DependencyInterface extends \Stringable { } diff --git a/src/Toolkit/src/Dependency/PhpPackageDependency.php b/src/Toolkit/src/Dependency/PhpPackageDependency.php index c66286979fc..fed6e153540 100644 --- a/src/Toolkit/src/Dependency/PhpPackageDependency.php +++ b/src/Toolkit/src/Dependency/PhpPackageDependency.php @@ -20,7 +20,7 @@ * * @author Hugo Alliaume */ -final class PhpPackageDependency implements Dependency +final class PhpPackageDependency implements DependencyInterface { /** * @param non-empty-string $name diff --git a/src/Toolkit/src/Dependency/StimulusControllerDependency.php b/src/Toolkit/src/Dependency/StimulusControllerDependency.php index 00b3c8db9b2..f1319b18033 100644 --- a/src/Toolkit/src/Dependency/StimulusControllerDependency.php +++ b/src/Toolkit/src/Dependency/StimulusControllerDependency.php @@ -20,7 +20,7 @@ * * @author Hugo Alliaume */ -final class StimulusControllerDependency implements Dependency +final class StimulusControllerDependency implements DependencyInterface { /** * @param non-empty-string $name diff --git a/src/Toolkit/src/Registry/GitHubRegistry.php b/src/Toolkit/src/Registry/GitHubRegistry.php index 9fb9f2b4b3a..43f3104b7b0 100644 --- a/src/Toolkit/src/Registry/GitHubRegistry.php +++ b/src/Toolkit/src/Registry/GitHubRegistry.php @@ -24,7 +24,7 @@ * @author Jean-François Lépine * @author Hugo Alliaume */ -final class GitHubRegistry implements Registry +final class GitHubRegistry implements RegistryInterface { public function __construct( private readonly KitFactory $kitFactory, diff --git a/src/Toolkit/src/Registry/LocalRegistry.php b/src/Toolkit/src/Registry/LocalRegistry.php index 2cdb9b6e4a7..dc8577ce955 100644 --- a/src/Toolkit/src/Registry/LocalRegistry.php +++ b/src/Toolkit/src/Registry/LocalRegistry.php @@ -23,7 +23,7 @@ * @author Jean-François Lépine * @author Hugo Alliaume */ -final class LocalRegistry implements Registry +final class LocalRegistry implements RegistryInterface { private static string $kitsDir = __DIR__.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'kits'; diff --git a/src/Toolkit/src/Registry/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php index d9ed7151899..e9a0e50ecda 100644 --- a/src/Toolkit/src/Registry/RegistryFactory.php +++ b/src/Toolkit/src/Registry/RegistryFactory.php @@ -29,7 +29,7 @@ public function __construct( /** * @throws \InvalidArgumentException */ - public function getForKit(string $kit): Registry + public function getForKit(string $kit): RegistryInterface { $type = match (true) { GitHubRegistry::supports($kit) => Type::GitHub, diff --git a/src/Toolkit/src/Registry/Registry.php b/src/Toolkit/src/Registry/RegistryInterface.php similarity index 95% rename from src/Toolkit/src/Registry/Registry.php rename to src/Toolkit/src/Registry/RegistryInterface.php index fb7e602c843..246ddadac99 100644 --- a/src/Toolkit/src/Registry/Registry.php +++ b/src/Toolkit/src/Registry/RegistryInterface.php @@ -19,7 +19,7 @@ * @author Jean-François Lépine * @author Hugo Alliaume */ -interface Registry +interface RegistryInterface { public static function supports(string $kitName): bool; From a0f537955018ea4c377982ffb9bb9f0eb57aa923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Tue, 6 May 2025 04:51:50 +0200 Subject: [PATCH 41/58] [Toolkit] Fix deprecation Kernel > 7.3 --- src/Toolkit/tests/Fixtures/Kernel.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php index d1853b06649..7ea7eb0574f 100644 --- a/src/Toolkit/tests/Fixtures/Kernel.php +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -45,6 +45,10 @@ protected function configureContainer(ContainerConfigurator $container): void 'property_access' => true, 'http_client' => true, 'handle_all_throwables' => true, + + ...(self::VERSION_ID >= 70300 ? [ + 'property_info' => ['with_constructor_extractor' => false], + ] : []), ]); $container->extension('twig', [ From c9e0f4faeca0e1bedd75ac99b0564bf230371b72 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 5 May 2025 06:47:43 +0200 Subject: [PATCH 42/58] [Toolkit] Remove redundant code in Shadcn kit components --- src/Toolkit/kits/shadcn/templates/components/Alert.html.twig | 2 +- .../shadcn/templates/components/Alert/Description.html.twig | 2 +- .../kits/shadcn/templates/components/Alert/Title.html.twig | 2 +- .../kits/shadcn/templates/components/AspectRatio.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig | 2 +- .../kits/shadcn/templates/components/Avatar/Image.html.twig | 2 +- .../kits/shadcn/templates/components/Avatar/Text.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Badge.html.twig | 2 +- .../shadcn/templates/components/Breadcrumb/Ellipsis.html.twig | 2 +- .../kits/shadcn/templates/components/Breadcrumb/Item.html.twig | 2 +- .../kits/shadcn/templates/components/Breadcrumb/Link.html.twig | 2 +- .../kits/shadcn/templates/components/Breadcrumb/List.html.twig | 2 +- .../kits/shadcn/templates/components/Breadcrumb/Page.html.twig | 2 +- .../shadcn/templates/components/Breadcrumb/Separator.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Button.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Card.html.twig | 2 +- .../kits/shadcn/templates/components/Card/Content.html.twig | 2 +- .../kits/shadcn/templates/components/Card/Description.html.twig | 2 +- .../kits/shadcn/templates/components/Card/Footer.html.twig | 2 +- .../kits/shadcn/templates/components/Card/Header.html.twig | 2 +- .../kits/shadcn/templates/components/Card/Title.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Input.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Label.html.twig | 2 +- .../kits/shadcn/templates/components/Pagination.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Progress.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Select.html.twig | 2 +- .../kits/shadcn/templates/components/Separator.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Switch.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Table.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Body.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Caption.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Cell.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Footer.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Head.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Header.html.twig | 2 +- .../kits/shadcn/templates/components/Table/Row.html.twig | 2 +- src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig | 2 +- 39 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig index 36ae0d2a9ed..d5ae97b9894 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig @@ -12,7 +12,7 @@ diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig index 159efda191c..712d8722850 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig @@ -1,6 +1,6 @@

      {%- block content %}{% endblock -%}

      diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig index d2585502353..5a47394168a 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig @@ -1,6 +1,6 @@
      {%- block content %}{% endblock -%}
      diff --git a/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig index 93c3d1a4a5d..f10b04ff765 100644 --- a/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig @@ -1,7 +1,7 @@ {%- props ratio, style = '' -%}
      {%- block content %}{% endblock -%}
      diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig index 3ce5a9ae4cb..40fd86e29fa 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig index 2863553f8df..0b1dcac2e93 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig @@ -1,4 +1,4 @@ diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig index 553e1de92af..0422495a095 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig b/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig index a5a9386658d..aa53c0920aa 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig @@ -12,7 +12,7 @@ ) -%}
      {%- block content %}{% endblock -%}
      diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig index 02152b1baa2..32aa63b39eb 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig @@ -1,6 +1,6 @@
      diff --git a/src/Toolkit/kits/shadcn/templates/components/Select.html.twig b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig index aad4affc10b..b3c11aa0d8a 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Select.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig @@ -1,6 +1,6 @@ diff --git a/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig index b85e39e0408..666c858cb7e 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig @@ -13,6 +13,6 @@ {{ attributes.defaults({ role: decorative ? 'none' : 'separator', 'aria-orientation': decorative ? false : orientation, - }).without('class') }} + }) }} >
      diff --git a/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig index 0e1301616a7..22e0e3612c8 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig @@ -1,4 +1,4 @@
      diff --git a/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig index dac0998fb52..d228265d0fe 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig @@ -1,4 +1,4 @@ diff --git a/src/Toolkit/kits/shadcn/templates/components/Table.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig index 48c55aa2d93..b9f3399d446 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig @@ -1,7 +1,7 @@
      {%- block content %}{% endblock -%}
      diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig index f7efc6bc957..a9c34280da5 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig index aa41d4be8ba..e737398c135 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig index 9a01544a0e9..51fe7c95af0 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig index 07503fbe3aa..5e4a5628e6e 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig index 33273f96ea6..bfa630a91f3 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig index 610756b8c59..94aee678f4b 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig index 4e01c5c8850..e58858a2053 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig @@ -1,6 +1,6 @@ {%- block content %}{% endblock -%} diff --git a/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig index 399593a0e8a..317a57e1774 100644 --- a/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig +++ b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig @@ -1,4 +1,4 @@ From 726305effbd62e559f9fd43f3178dc65251d06ba Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 5 May 2025 02:30:03 +0200 Subject: [PATCH 43/58] [Site] Rename CodePreview_Tabs to Toolkit_Tabs, extract Toolkit's documentation tabs rendering into ToolkitService --- ux.symfony.com/assets/styles/app.scss | 2 +- ...dePreview_Tabs.scss => _Toolkit_Tabs.scss} | 18 +++---- .../Service/CommonMark/ConverterFactory.php | 6 +-- .../CodeBlockRenderer/CodeBlockRenderer.php | 41 +++------------ .../src/Service/Toolkit/ToolkitService.php | 51 ++++++++++++++++++- 5 files changed, 70 insertions(+), 48 deletions(-) rename ux.symfony.com/assets/styles/components/{_CodePreview_Tabs.scss => _Toolkit_Tabs.scss} (75%) diff --git a/ux.symfony.com/assets/styles/app.scss b/ux.symfony.com/assets/styles/app.scss index dcedeeed626..5b86e0251e6 100644 --- a/ux.symfony.com/assets/styles/app.scss +++ b/ux.symfony.com/assets/styles/app.scss @@ -128,7 +128,6 @@ $utilities: map-remove( @import "components/Button"; @import "components/Browser"; @import "components/Changelog"; -@import "components/CodePreview_Tabs"; @import "components/DataList"; @import "components/DemoContainer"; @import "components/DemoCard"; @@ -153,6 +152,7 @@ $utilities: map-remove( @import "components/Terminal"; @import "components/TerminalCommand"; @import "components/ThemeSwitcher"; +@import "components/Toolkit_Tabs"; @import "components/Wysiwyg"; // Utilities diff --git a/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss b/ux.symfony.com/assets/styles/components/_Toolkit_Tabs.scss similarity index 75% rename from ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss rename to ux.symfony.com/assets/styles/components/_Toolkit_Tabs.scss index 0398deaffa5..d3871a5d65a 100644 --- a/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss +++ b/ux.symfony.com/assets/styles/components/_Toolkit_Tabs.scss @@ -1,13 +1,13 @@ -.CodePreview_Tabs { +.Toolkit_Tabs { } -.CodePreview_TabHead { +.Toolkit_TabHead { display: flex; flex-direction: row; margin-bottom: 1rem } -.CodePreview_TabControl { +.Toolkit_TabControl { border-bottom: 3px solid transparent; color: var(--bs-primary-color); padding: 0 1rem; @@ -18,24 +18,24 @@ margin-bottom: -1px; } -.CodePreview_TabControl.active { +.Toolkit_TabControl.active { border-color: var(--bs-secondary-color); } -.CodePreview_TabPanel { +.Toolkit_TabPanel { position: relative; } -.CodePreview_TabPanel:not(.active) { +.Toolkit_TabPanel:not(.active) { display: none; } -.CodePreview_TabPanel:has(.CodePreview_Preview) { +.Toolkit_TabPanel:has(.Toolkit_Preview) { border: 1px solid var(--bs-border-color); border-radius: .75rem } -.CodePreview_Loader { +.Toolkit_Loader { width: 100%; display: flex; justify-content: center; @@ -48,7 +48,7 @@ } } -.CodePreview_Preview { +.Toolkit_Preview { width: 100%; transition: opacity .250s linear; border-radius: .75rem; diff --git a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php index 4da4c02d84a..691fec5ae0f 100644 --- a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php +++ b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php @@ -12,6 +12,7 @@ namespace App\Service\CommonMark; use App\Service\CommonMark\Extension\CodeBlockRenderer\CodeBlockRenderer; +use App\Service\Toolkit\ToolkitService; use League\CommonMark\CommonMarkConverter; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Extension\ExternalLink\ExternalLinkExtension; @@ -28,8 +29,7 @@ final class ConverterFactory { public function __construct( - private readonly UrlGeneratorInterface $urlGenerator, - private readonly UriSigner $uriSigner, + private readonly ToolkitService $toolkitService, ) { } @@ -57,7 +57,7 @@ public function __invoke(): CommonMarkConverter ->addExtension(new ExternalLinkExtension()) ->addExtension(new MentionExtension()) ->addExtension(new FrontMatterExtension()) - ->addRenderer(FencedCode::class, new CodeBlockRenderer($this->urlGenerator, $this->uriSigner)) + ->addRenderer(FencedCode::class, new CodeBlockRenderer($this->toolkitService)) ; return $converter; diff --git a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php index 43174362f7b..9f6cd83512f 100644 --- a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -11,20 +11,19 @@ namespace App\Service\CommonMark\Extension\CodeBlockRenderer; +use App\Enum\ToolkitKitId; +use App\Service\Toolkit\ToolkitService; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Node\Node; use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; -use Symfony\Component\HttpFoundation\UriSigner; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Tempest\Highlight\Highlighter; use Tempest\Highlight\WebTheme; final readonly class CodeBlockRenderer implements NodeRendererInterface { public function __construct( - private UrlGeneratorInterface $urlGenerator, - private UriSigner $uriSigner, + private ToolkitService $toolkitService, ) { } @@ -38,39 +37,13 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer): \ $infoWords = $node->getInfoWords(); $language = $infoWords[0] ?? 'txt'; $options = isset($infoWords[1]) && json_validate($infoWords[1]) ? json_decode($infoWords[1], true) : []; + $kitId = ToolkitKitId::tryFrom($options['kit'] ?? null); $preview = $options['preview'] ?? false; - $kit = $options['kit'] ?? null; - $height = $options['height'] ?? '150px'; - $code = $node->getLiteral(); + $output = $this->highlightCode($code = $node->getLiteral(), $language); - $output = $this->highlightCode($code, $language); - - if ($preview && $kit) { - $previewUrl = $this->uriSigner->sign($this->urlGenerator->generate('app_toolkit_component_preview', [ - 'kitId' => $kit, - 'code' => $code, - 'height' => $height, - ], UrlGeneratorInterface::ABSOLUTE_URL)); - - $output = << - -
      -
      -
      - - Loading... -
      - -
      -
      {$output}
      -
      -
      -HTML; + if ($kitId && $preview) { + $output = $this->toolkitService->renderComponentPreviewCodeTabs($kitId, $code, $output, $options['height'] ?? '150px'); } return $output; diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php index ef4807d7bb8..f7bb94e9f9d 100644 --- a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -13,15 +13,20 @@ use App\Enum\ToolkitKitId; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\UX\Toolkit\Asset\Component; use Symfony\UX\Toolkit\Kit\Kit; use Symfony\UX\Toolkit\Registry\RegistryFactory; +use function Symfony\Component\String\s; class ToolkitService { public function __construct( #[Autowire(service: 'ux_toolkit.registry.registry_factory')] - private RegistryFactory $registryFactory, + private readonly RegistryFactory $registryFactory, + private readonly UriSigner $uriSigner, + private readonly UrlGeneratorInterface $urlGenerator ) { } @@ -54,4 +59,48 @@ public function getDocumentableComponents(Kit $kit): array { return array_filter($kit->getComponents(), fn (Component $component) => $component->doc); } + + public function renderComponentPreviewCodeTabs(ToolkitKitId $kitId, string $code, string $highlightedCode, string $height): string + { + $previewUrl = $this->urlGenerator->generate('app_toolkit_component_preview', ['kitId' => $kitId->value, 'code' => $code, 'height' => $height], UrlGeneratorInterface::ABSOLUTE_URL); + $previewUrl = $this->uriSigner->sign($previewUrl); + + return self::generateTabs([ + 'Preview' => << + + Loading... +
      + + HTML, + 'Code' => $highlightedCode + ]); + } + + + /** + * @param non-empty-array $tabs + */ + private static function generateTabs(array $tabs): string + { + $activeTabId = null; + $tabsControls = ''; + $tabsPanels = ''; + + foreach ($tabs as $tabText => $tabContent) { + $tabId = hash('xxh3', $tabText); + $activeTabId ??= $tabId; + $isActive = $activeTabId === $tabId; + + $tabsControls .= sprintf('', $tabId, $isActive ? 'true' : 'false', trim($tabText)); + $tabsPanels .= sprintf('
      %s
      ', $isActive ? 'active' : '', $tabId, $tabContent); + } + + return << + +
      {$tabsPanels}
      +
      +HTML; + } } From c987f6000ee8261fea33da43c599e598a3ec8e0d Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 5 May 2025 03:48:10 +0200 Subject: [PATCH 44/58] [Site] Add manual installation steps for Toolkit kits's components --- .../CodeBlockRenderer/CodeBlockRenderer.php | 4 +- .../src/Service/Toolkit/ToolkitService.php | 51 +++++++++++++++++++ .../Twig/Components/Toolkit/ComponentDoc.php | 27 +++------- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php index 9f6cd83512f..6d01412aacc 100644 --- a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -62,9 +62,7 @@ private function highlightCode(string $code, string $language): string return <<
      -
      - {$output} -
      +
      {$output}
      HTML; diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php index f7bb94e9f9d..b6018a3fd6e 100644 --- a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -12,10 +12,13 @@ namespace App\Service\Toolkit; use App\Enum\ToolkitKitId; +use App\Util\SourceCleaner; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Installer\PoolResolver; use Symfony\UX\Toolkit\Kit\Kit; use Symfony\UX\Toolkit\Registry\RegistryFactory; use function Symfony\Component\String\s; @@ -77,6 +80,41 @@ public function renderComponentPreviewCodeTabs(ToolkitKitId $kitId, string $code ]); } + public function renderInstallationSteps(ToolkitKitId $kitId, Component $component): string + { + $kit = $this->getKit($kitId); + $pool = (new PoolResolver)->resolveForComponent($kit, $component); + + $manual = '

      The UX Toolkit is not mandatory to install a component. You can install it manually by following the next steps:

      '; + $manual .= '
        '; + $manual .= '
      1. Copy the files into your Symfony app:'; + foreach ($pool->getFiles() as $file) { + $manual .= sprintf( + "
        %s\n%s\n
        ", + $file->relativePathNameToKit, + sprintf("\n```%s\n%s\n```", pathinfo($file->relativePathNameToKit, PATHINFO_EXTENSION), trim(file_get_contents(Path::join($kit->path, $file->relativePathNameToKit)))) + ); + } + $manual .= '
      2. '; + + if ($phpPackageDependencies = $pool->getPhpPackageDependencies()) { + $manual .= '
      3. If necessary, install the following Composer dependencies:'; + $manual .= self::generateTerminal('shell', SourceCleaner::processTerminalLines('composer require ' . implode(' ', $phpPackageDependencies))); + $manual .= '
      4. '; + } + + $manual .= '
      5. And the most important, enjoy!
      6. '; + $manual .= '
      '; + + return $this->generateTabs([ + 'Automatic' => sprintf( + '

      Ensure the Symfony UX Toolkit is installed in your Symfony app:

      %s

      Then, run the following command to install the component and its dependencies:

      %s', + self::generateTerminal('shell', SourceCleaner::processTerminalLines('composer require --dev symfony/ux-toolkit'), 'margin-bottom: 1rem'), + self::generateTerminal('shell', SourceCleaner::processTerminalLines("bin/console ux:toolkit:install-component {$component->name} --kit {$kitId->value}"), 'margin-bottom: 1rem'), + ), + 'Manual' => $manual, + ]); + } /** * @param non-empty-array $tabs @@ -103,4 +141,17 @@ private static function generateTabs(array $tabs): string HTML; } + + private static function generateTerminal(string $language, string $content, string $style = ''): string + { + return << +
      +
      +
      {$content}
      +
      +
      + + HTML; + } } diff --git a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php index d3a003f5f7c..c52062aad82 100644 --- a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -12,6 +12,7 @@ namespace App\Twig\Components\Toolkit; use App\Enum\ToolkitKitId; +use App\Service\Toolkit\ToolkitService; use App\Util\SourceCleaner; use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -28,12 +29,7 @@ class ComponentDoc public ToolkitKitId $kitId; public Component $component; - public function __construct( - private readonly UrlGeneratorInterface $urlGenerator, - private readonly UriSigner $uriSigner, - private readonly Highlighter $highlighter, - private readonly \Twig\Environment $twig, - ) { + public function __construct(private readonly ToolkitService $toolkitService) { } public function getContent(): string @@ -54,23 +50,9 @@ private function formatContent(string $markdownContent): string private function insertInstallation(AbstractString $markdownContent): AbstractString { - $installationCode = SourceCleaner::processTerminalLines(<<component->name} --kit {$this->kitId->value} -SHELL - ); - - // TODO: Provide tabs showing automatic and manual installation return $markdownContent->replace( '', - << -
      -
      -
      {$installationCode}
      -
      -
      - - HTML + $this->toolkitService->renderInstallationSteps($this->kitId, $this->component) ); } @@ -85,6 +67,9 @@ private function insertUsage(AbstractString $markdownContent): AbstractString ); } + /** + * Iterate over code blocks, and add the option "kit" if the option "preview" exists. + */ private function adaptPreviewableCodeBlocks(AbstractString $markdownContent): AbstractString { return $markdownContent->replaceMatches('/```(?P[a-z]+) +(?P\{.+?\})\n/', function (array $matches) { From d2086da5435a4b2f4c5aa1a4c5abd4a0c00221be Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 6 May 2025 18:23:23 +0900 Subject: [PATCH 45/58] [Site] Fix Terminal component styles, ensure it does not grow, and display scrollbars --- .../assets/styles/components/_Terminal.scss | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/ux.symfony.com/assets/styles/components/_Terminal.scss b/ux.symfony.com/assets/styles/components/_Terminal.scss index 60be7230a8f..f9cb82c7e9f 100644 --- a/ux.symfony.com/assets/styles/components/_Terminal.scss +++ b/ux.symfony.com/assets/styles/components/_Terminal.scss @@ -6,6 +6,7 @@ border-radius: .75rem; position: relative; font-size: 12px; + display: grid; // Ensure the Terminal overflow its parent if "pre" contains a very-long-line of the same highlighted element (e.g.: a long string) } .Terminal_light { @@ -162,6 +163,7 @@ overflow: visible; } scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, .8) transparent; pre { background: none; } @@ -170,15 +172,15 @@ } } -@media screen and (min-width: 768px) { - .Terminal_content::-webkit-scrollbar { - display: none; - } - .Terminal_content { - --webkit-scrollbar-width: none; - scrollbar-width: none; - } -} +// @media screen and (min-width: 768px) { +// .Terminal_content::-webkit-scrollbar { +// display: none; +// } +// .Terminal_content { +// --webkit-scrollbar-width: none; +// scrollbar-width: none; +// } +// } .Terminal_expand { position: absolute; From ae4d71de042068073960ece14c04cfe03ab2dc7d Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 6 May 2025 18:24:01 +0900 Subject: [PATCH 46/58] [Site] Refactor and use the CodeBlockRenderer::highlightCode() method to render the highlighted code inside a Terminal component --- .../Service/CommonMark/ConverterFactory.php | 2 - .../CodeBlockRenderer/CodeBlockRenderer.php | 8 ++-- .../src/Service/Toolkit/ToolkitService.php | 40 ++++++------------- .../Twig/Components/Toolkit/ComponentDoc.php | 7 +--- 4 files changed, 19 insertions(+), 38 deletions(-) diff --git a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php index 691fec5ae0f..9ed9c4991b1 100644 --- a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php +++ b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php @@ -19,8 +19,6 @@ use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\Mention\MentionExtension; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Symfony\Component\HttpFoundation\UriSigner; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * @author Kevin Bond diff --git a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php index 6d01412aacc..522bd4a4d8d 100644 --- a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -23,7 +23,7 @@ final readonly class CodeBlockRenderer implements NodeRendererInterface { public function __construct( - private ToolkitService $toolkitService, + private ToolkitService $toolkitService, ) { } @@ -40,7 +40,7 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer): \ $kitId = ToolkitKitId::tryFrom($options['kit'] ?? null); $preview = $options['preview'] ?? false; - $output = $this->highlightCode($code = $node->getLiteral(), $language); + $output = $this->highlightCode($language, $code = $node->getLiteral()); if ($kitId && $preview) { $output = $this->toolkitService->renderComponentPreviewCodeTabs($kitId, $code, $output, $options['height'] ?? '150px'); @@ -49,7 +49,7 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer): \ return $output; } - private function highlightCode(string $code, string $language): string + public static function highlightCode(string $language, string $code, string $style = 'margin-bottom: 1rem'): string { $highlighter = new Highlighter(); @@ -60,7 +60,7 @@ private function highlightCode(string $code, string $language): string : '
      '.$parsed.'
      '; return << +
      {$output}
      diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php index b6018a3fd6e..c56c1a64f56 100644 --- a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -12,7 +12,7 @@ namespace App\Service\Toolkit; use App\Enum\ToolkitKitId; -use App\Util\SourceCleaner; +use App\Service\CommonMark\Extension\CodeBlockRenderer\CodeBlockRenderer; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpFoundation\UriSigner; @@ -21,7 +21,6 @@ use Symfony\UX\Toolkit\Installer\PoolResolver; use Symfony\UX\Toolkit\Kit\Kit; use Symfony\UX\Toolkit\Registry\RegistryFactory; -use function Symfony\Component\String\s; class ToolkitService { @@ -29,7 +28,7 @@ public function __construct( #[Autowire(service: 'ux_toolkit.registry.registry_factory')] private readonly RegistryFactory $registryFactory, private readonly UriSigner $uriSigner, - private readonly UrlGeneratorInterface $urlGenerator + private readonly UrlGeneratorInterface $urlGenerator, ) { } @@ -76,30 +75,30 @@ public function renderComponentPreviewCodeTabs(ToolkitKitId $kitId, string $code
      HTML, - 'Code' => $highlightedCode + 'Code' => $highlightedCode, ]); } public function renderInstallationSteps(ToolkitKitId $kitId, Component $component): string { $kit = $this->getKit($kitId); - $pool = (new PoolResolver)->resolveForComponent($kit, $component); + $pool = (new PoolResolver())->resolveForComponent($kit, $component); $manual = '

      The UX Toolkit is not mandatory to install a component. You can install it manually by following the next steps:

      '; $manual .= '
        '; - $manual .= '
      1. Copy the files into your Symfony app:'; + $manual .= '
      2. Copy the following file(s) into your Symfony app:'; foreach ($pool->getFiles() as $file) { - $manual .= sprintf( + $manual .= \sprintf( "
        %s\n%s\n
        ", $file->relativePathNameToKit, - sprintf("\n```%s\n%s\n```", pathinfo($file->relativePathNameToKit, PATHINFO_EXTENSION), trim(file_get_contents(Path::join($kit->path, $file->relativePathNameToKit)))) + \sprintf("\n```%s\n%s\n```", pathinfo($file->relativePathNameToKit, \PATHINFO_EXTENSION), trim(file_get_contents(Path::join($kit->path, $file->relativePathNameToKit)))) ); } $manual .= '
      3. '; if ($phpPackageDependencies = $pool->getPhpPackageDependencies()) { $manual .= '
      4. If necessary, install the following Composer dependencies:'; - $manual .= self::generateTerminal('shell', SourceCleaner::processTerminalLines('composer require ' . implode(' ', $phpPackageDependencies))); + $manual .= CodeBlockRenderer::highlightCode('shell', '$ composer require '.implode(' ', $phpPackageDependencies), 'margin-bottom: 0'); $manual .= '
      5. '; } @@ -107,10 +106,10 @@ public function renderInstallationSteps(ToolkitKitId $kitId, Component $componen $manual .= '
      '; return $this->generateTabs([ - 'Automatic' => sprintf( + 'Automatic' => \sprintf( '

      Ensure the Symfony UX Toolkit is installed in your Symfony app:

      %s

      Then, run the following command to install the component and its dependencies:

      %s', - self::generateTerminal('shell', SourceCleaner::processTerminalLines('composer require --dev symfony/ux-toolkit'), 'margin-bottom: 1rem'), - self::generateTerminal('shell', SourceCleaner::processTerminalLines("bin/console ux:toolkit:install-component {$component->name} --kit {$kitId->value}"), 'margin-bottom: 1rem'), + CodeBlockRenderer::highlightCode('shell', '$ composer require --dev symfony/ux-toolkit'), + CodeBlockRenderer::highlightCode('shell', "$ bin/console ux:toolkit:install-component {$component->name} --kit {$kitId->value}"), ), 'Manual' => $manual, ]); @@ -130,8 +129,8 @@ private static function generateTabs(array $tabs): string $activeTabId ??= $tabId; $isActive = $activeTabId === $tabId; - $tabsControls .= sprintf('', $tabId, $isActive ? 'true' : 'false', trim($tabText)); - $tabsPanels .= sprintf('
      %s
      ', $isActive ? 'active' : '', $tabId, $tabContent); + $tabsControls .= \sprintf('', $tabId, $isActive ? 'true' : 'false', trim($tabText)); + $tabsPanels .= \sprintf('
      %s
      ', $isActive ? 'active' : '', $tabId, $tabContent); } return << HTML; } - - private static function generateTerminal(string $language, string $content, string $style = ''): string - { - return << -
      -
      -
      {$content}
      -
      -
      - - HTML; - } } diff --git a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php index c52062aad82..6bfad3eed84 100644 --- a/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -13,13 +13,9 @@ use App\Enum\ToolkitKitId; use App\Service\Toolkit\ToolkitService; -use App\Util\SourceCleaner; -use Symfony\Component\HttpFoundation\UriSigner; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\String\AbstractString; use Symfony\UX\Toolkit\Asset\Component; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -use Tempest\Highlight\Highlighter; use function Symfony\Component\String\s; @@ -29,7 +25,8 @@ class ComponentDoc public ToolkitKitId $kitId; public Component $component; - public function __construct(private readonly ToolkitService $toolkitService) { + public function __construct(private readonly ToolkitService $toolkitService) + { } public function getContent(): string From 26c44dca814274d12f3794fb39bf46da35c773c4 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 21 Apr 2025 08:53:05 +0200 Subject: [PATCH 47/58] [Docs] Add step to disable `package.json` synchronization while upgrading PHP package --- src/Autocomplete/assets/README.md | 2 ++ src/Chartjs/assets/README.md | 2 ++ src/Cropperjs/assets/README.md | 2 ++ src/Dropzone/assets/README.md | 2 ++ src/LazyImage/assets/README.md | 2 ++ src/LiveComponent/assets/README.md | 2 ++ src/Map/src/Bridge/Google/assets/README.md | 2 ++ src/Map/src/Bridge/Leaflet/assets/README.md | 2 ++ src/Notify/assets/README.md | 2 ++ src/React/assets/README.md | 2 ++ src/Svelte/assets/README.md | 2 ++ src/Swup/assets/README.md | 2 ++ src/TogglePassword/assets/README.md | 2 ++ src/Translator/assets/README.md | 2 ++ src/Turbo/assets/README.md | 2 ++ src/Typed/assets/README.md | 2 ++ src/Vue/assets/README.md | 2 ++ 17 files changed, 34 insertions(+) diff --git a/src/Autocomplete/assets/README.md b/src/Autocomplete/assets/README.md index 6d9378371f1..95ab65ce9cf 100644 --- a/src/Autocomplete/assets/README.md +++ b/src/Autocomplete/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-autocomplete:2.23.0 npm add @symfony/ux-autocomplete@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-autocomplete/current/index.html) diff --git a/src/Chartjs/assets/README.md b/src/Chartjs/assets/README.md index 599beee143e..b5b0376054e 100644 --- a/src/Chartjs/assets/README.md +++ b/src/Chartjs/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-chartjs:2.23.0 npm add @symfony/ux-chartjs@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-chartjs/current/index.html) diff --git a/src/Cropperjs/assets/README.md b/src/Cropperjs/assets/README.md index b8be206d4ca..178ff0a48fd 100644 --- a/src/Cropperjs/assets/README.md +++ b/src/Cropperjs/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-cropperjs:2.23.0 npm add @symfony/ux-cropperjs@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-cropperjs/current/index.html) diff --git a/src/Dropzone/assets/README.md b/src/Dropzone/assets/README.md index ed3c563a57b..a9dcd553f05 100644 --- a/src/Dropzone/assets/README.md +++ b/src/Dropzone/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-dropzone:2.23.0 npm add @symfony/ux-dropzone@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-dropzone/current/index.html) diff --git a/src/LazyImage/assets/README.md b/src/LazyImage/assets/README.md index b45114d6d12..cb8d1cbd6ff 100644 --- a/src/LazyImage/assets/README.md +++ b/src/LazyImage/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-lazy-image:2.23.0 npm add @symfony/ux-lazy-image@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-lazy-image/current/index.html) diff --git a/src/LiveComponent/assets/README.md b/src/LiveComponent/assets/README.md index b544431e60a..1fa2bd8a1a0 100644 --- a/src/LiveComponent/assets/README.md +++ b/src/LiveComponent/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-live-component:2.23.0 npm add @symfony/ux-live-component@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-live-component/current/index.html) diff --git a/src/Map/src/Bridge/Google/assets/README.md b/src/Map/src/Bridge/Google/assets/README.md index e97d55bb7fd..917af847804 100644 --- a/src/Map/src/Bridge/Google/assets/README.md +++ b/src/Map/src/Bridge/Google/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-google-map:2.23.0 npm add @symfony/ux-google-map@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://github.com/symfony/ux/tree/2.x/src/Map/src/Bridge/Google) diff --git a/src/Map/src/Bridge/Leaflet/assets/README.md b/src/Map/src/Bridge/Leaflet/assets/README.md index 9092867f6a8..ff134243eee 100644 --- a/src/Map/src/Bridge/Leaflet/assets/README.md +++ b/src/Map/src/Bridge/Leaflet/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-leaflet-map:2.23.0 npm add @symfony/ux-leaflet-map@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://github.com/symfony/ux/tree/2.x/src/Map/src/Bridge/Google) diff --git a/src/Notify/assets/README.md b/src/Notify/assets/README.md index 398c9db48fd..b2723340a52 100644 --- a/src/Notify/assets/README.md +++ b/src/Notify/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-notify:2.23.0 npm add @symfony/ux-notify@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-notify/current/index.html) diff --git a/src/React/assets/README.md b/src/React/assets/README.md index c385d241c61..c1996d8b8e1 100644 --- a/src/React/assets/README.md +++ b/src/React/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-react:2.23.0 npm add @symfony/ux-react@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-react/current/index.html) diff --git a/src/Svelte/assets/README.md b/src/Svelte/assets/README.md index f430011b8c7..1e029f9f54c 100644 --- a/src/Svelte/assets/README.md +++ b/src/Svelte/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-svelte:2.23.0 npm add @symfony/ux-svelte@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-svelte/current/index.html) diff --git a/src/Swup/assets/README.md b/src/Swup/assets/README.md index 228191f68dd..d451b6ca9db 100644 --- a/src/Swup/assets/README.md +++ b/src/Swup/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-swup:2.23.0 npm add @symfony/ux-swup@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-swup/current/index.html) diff --git a/src/TogglePassword/assets/README.md b/src/TogglePassword/assets/README.md index 0290c77d1e7..221d74519a3 100644 --- a/src/TogglePassword/assets/README.md +++ b/src/TogglePassword/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-toggle-password:2.23.0 npm add @symfony/ux-toggle-password@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-toggle-password/current/index.html) diff --git a/src/Translator/assets/README.md b/src/Translator/assets/README.md index 42222132a70..c5d5d125068 100644 --- a/src/Translator/assets/README.md +++ b/src/Translator/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-translator:2.23.0 npm add @symfony/ux-translator@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-translator/current/index.html) diff --git a/src/Turbo/assets/README.md b/src/Turbo/assets/README.md index 225febf37ad..02a860f81c4 100644 --- a/src/Turbo/assets/README.md +++ b/src/Turbo/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-turbo:2.23.0 npm add @symfony/ux-turbo@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-turbo/current/index.html) diff --git a/src/Typed/assets/README.md b/src/Typed/assets/README.md index fa75bb72e15..3e29914369c 100644 --- a/src/Typed/assets/README.md +++ b/src/Typed/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-typed:2.23.0 npm add @symfony/ux-typed@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-typed/current/index.html) diff --git a/src/Vue/assets/README.md b/src/Vue/assets/README.md index d46f07d638d..6ffc86511af 100644 --- a/src/Vue/assets/README.md +++ b/src/Vue/assets/README.md @@ -14,6 +14,8 @@ composer require symfony/ux-vue:2.23.0 npm add @symfony/ux-vue@2.23.0 ``` +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + ## Resources - [Documentation](https://symfony.com/bundles/ux-vue/current/index.html) From 8cb5384d3df3d9f4adf183d52a96e378354816fb Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 8 May 2025 15:25:10 +0900 Subject: [PATCH 48/58] [Toolkit] Introduce KitContextRunner, to run code in the context of a given Kit --- src/Toolkit/config/services.php | 8 ++ src/Toolkit/src/Kit/KitContextRunner.php | 107 ++++++++++++++++++ .../tests/Kit/KitContextRunnerTest.php | 63 +++++++++++ .../Toolkit/ComponentsController.php | 56 +-------- 4 files changed, 182 insertions(+), 52 deletions(-) create mode 100644 src/Toolkit/src/Kit/KitContextRunner.php create mode 100644 src/Toolkit/tests/Kit/KitContextRunnerTest.php diff --git a/src/Toolkit/config/services.php b/src/Toolkit/config/services.php index 23a6e5289c3..3406e962806 100644 --- a/src/Toolkit/config/services.php +++ b/src/Toolkit/config/services.php @@ -13,6 +13,7 @@ use Symfony\UX\Toolkit\Command\DebugKitCommand; use Symfony\UX\Toolkit\Command\InstallComponentCommand; +use Symfony\UX\Toolkit\Kit\KitContextRunner; use Symfony\UX\Toolkit\Kit\KitFactory; use Symfony\UX\Toolkit\Kit\KitSynchronizer; use Symfony\UX\Toolkit\Registry\GitHubRegistry; @@ -75,5 +76,12 @@ ->args([ service('filesystem'), ]) + + ->set('ux_toolkit.kit.kit_context_runner', KitContextRunner::class) + ->public() + ->args([ + service('twig'), + service('ux.twig_component.component_factory'), + ]) ; }; diff --git a/src/Toolkit/src/Kit/KitContextRunner.php b/src/Toolkit/src/Kit/KitContextRunner.php new file mode 100644 index 00000000000..f2cd0e4221b --- /dev/null +++ b/src/Toolkit/src/Kit/KitContextRunner.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Kit; + +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\File\FileType; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; +use Twig\Loader\ChainLoader; +use Twig\Loader\FilesystemLoader; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class KitContextRunner +{ + public function __construct( + private readonly \Twig\Environment $twig, + private readonly ComponentFactory $componentFactory, + ) { + } + + /** + * @template TResult of mixed + * + * @param callable(Kit): TResult $callback + * + * @return TResult + */ + public function runForKit(Kit $kit, callable $callback): mixed + { + $resetServices = $this->contextualizeServicesForKit($kit); + + try { + return $callback($kit); + } finally { + $resetServices(); + } + } + + /** + * @return callable(): void Reset the services when called + */ + private function contextualizeServicesForKit(Kit $kit): callable + { + // Configure Twig + $initialTwigLoader = $this->twig->getLoader(); + $this->twig->setLoader(new ChainLoader([ + new FilesystemLoader(Path::join($kit->path, 'templates/components')), + $initialTwigLoader, + ])); + + // Configure Twig Components + $reflComponentFactory = new \ReflectionClass($this->componentFactory); + + $reflComponentFactoryConfig = $reflComponentFactory->getProperty('config'); + $initialComponentFactoryConfig = $reflComponentFactoryConfig->getValue($this->componentFactory); + $reflComponentFactoryConfig->setValue($this->componentFactory, []); + + $reflComponentFactoryComponentTemplateFinder = $reflComponentFactory->getProperty('componentTemplateFinder'); + $initialComponentFactoryComponentTemplateFinder = $reflComponentFactoryComponentTemplateFinder->getValue($this->componentFactory); + $reflComponentFactoryComponentTemplateFinder->setValue($this->componentFactory, $this->createComponentTemplateFinder($kit)); + + return function () use ($initialTwigLoader, $reflComponentFactoryConfig, $initialComponentFactoryConfig, $reflComponentFactoryComponentTemplateFinder, $initialComponentFactoryComponentTemplateFinder) { + $this->twig->setLoader($initialTwigLoader); + $reflComponentFactoryConfig->setValue($this->componentFactory, $initialComponentFactoryConfig); + $reflComponentFactoryComponentTemplateFinder->setValue($this->componentFactory, $initialComponentFactoryComponentTemplateFinder); + }; + } + + private function createComponentTemplateFinder(Kit $kit): ComponentTemplateFinderInterface + { + static $instances = []; + + return $instances[$kit->name] ?? new class($kit) implements ComponentTemplateFinderInterface { + public function __construct(private readonly Kit $kit) + { + } + + public function findAnonymousComponentTemplate(string $name): ?string + { + if (null === $component = $this->kit->getComponent($name)) { + throw new \RuntimeException(\sprintf('Component "%s" does not exist in kit "%s".', $name, $this->kit->name)); + } + + foreach ($component->files as $file) { + if (FileType::Twig === $file->type) { + return $file->relativePathName; + } + } + + throw new \LogicException(\sprintf('No Twig files found for component "%s" in kit "%s", it should not happens.', $name, $this->kit->name)); + } + }; + } +} diff --git a/src/Toolkit/tests/Kit/KitContextRunnerTest.php b/src/Toolkit/tests/Kit/KitContextRunnerTest.php new file mode 100644 index 00000000000..3a60afd83d4 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitContextRunnerTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Kit\KitContextRunner; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentTemplateFinder; +use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; + +class KitContextRunnerTest extends KernelTestCase +{ + public function testRunForKitShouldConfigureThenResetServices(): void + { + $twig = self::getContainer()->get('twig'); + $initialTwigLoader = $twig->getLoader(); + + $componentFactory = self::getContainer()->get('ux.twig_component.component_factory'); + $initialComponentFactoryState = $this->extractComponentFactoryState($componentFactory); + $this->assertInstanceOf(ComponentTemplateFinder::class, $initialComponentFactoryState['componentTemplateFinder']); + $this->assertIsArray($initialComponentFactoryState['config']); + + $executed = false; + $kitContextRunner = self::getContainer()->get('ux_toolkit.kit.kit_context_runner'); + $kitContextRunner->runForKit(self::getContainer()->get('ux_toolkit.registry.local')->getKit('shadcn'), function () use (&$executed, $twig, $initialTwigLoader, $componentFactory, $initialComponentFactoryState) { + $executed = true; + + $this->assertNotEquals($initialTwigLoader, $twig->getLoader(), 'The Twig loader must be different in this current kit-aware context.'); + $this->assertNotEquals($initialComponentFactoryState, $this->extractComponentFactoryState($componentFactory), 'The ComponentFactory state must be different in this current kit-aware context.'); + + $template = $twig->createTemplate('Hello world'); + $renderedTemplate = $template->render(); + + $this->assertNotEmpty($renderedTemplate); + $this->assertStringContainsString('Hello world', $renderedTemplate); + $this->assertStringContainsString('class="', $renderedTemplate); + }); + $this->assertTrue($executed, \sprintf('The callback passed to %s::runForKit() has not been executed.', KitContextRunner::class)); + + $this->assertEquals($initialTwigLoader, $twig->getLoader(), 'The Twig loader must be back to its original implementation.'); + $this->assertEquals($initialComponentFactoryState, $this->extractComponentFactoryState($componentFactory), 'The ComponentFactory must be back to its original state.'); + } + + /** + * @return array{componentTemplateFinder: ComponentTemplateFinderInterface::class, config: array} + */ + private function extractComponentFactoryState(ComponentFactory $componentFactory): array + { + $componentTemplateFinder = \Closure::bind(fn (ComponentFactory $componentFactory) => $componentFactory->componentTemplateFinder, null, $componentFactory)($componentFactory); + $config = \Closure::bind(fn (ComponentFactory $componentFactory) => $componentFactory->config, null, $componentFactory)($componentFactory); + + return ['componentTemplateFinder' => $componentTemplateFinder, 'config' => $config]; + } +} diff --git a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php index bb70a547771..bcfde7b0d41 100644 --- a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php +++ b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php @@ -23,12 +23,7 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Profiler\Profiler; use Symfony\Component\Routing\Attribute\Route; -use Symfony\UX\Toolkit\File\FileType; -use Symfony\UX\Toolkit\Kit\Kit; -use Symfony\UX\TwigComponent\ComponentFactory; -use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; -use Twig\Loader\ChainLoader; -use Twig\Loader\FilesystemLoader; +use Symfony\UX\Toolkit\Kit\KitContextRunner; class ComponentsController extends AbstractController { @@ -75,8 +70,8 @@ public function previewComponent( #[MapQueryParameter] string $height, UriSigner $uriSigner, \Twig\Environment $twig, - #[Autowire(service: 'ux.twig_component.component_factory')] - ComponentFactory $componentFactory, + #[Autowire(service: 'ux_toolkit.kit.kit_context_runner')] + KitContextRunner $kitContextRunner, #[Autowire(service: 'profiler')] ?Profiler $profiler, ): Response { @@ -88,34 +83,6 @@ public function previewComponent( $kit = $this->toolkitService->getKit($kitId); - $twig->setLoader(new ChainLoader([ - new FilesystemLoader($kit->path.\DIRECTORY_SEPARATOR.'templates'.\DIRECTORY_SEPARATOR.'components'), - $twig->getLoader(), - ])); - - $this->tweakComponentFactory( - $componentFactory, - new class($kit) implements ComponentTemplateFinderInterface { - public function __construct( - private readonly Kit $kit, - ) { - } - - public function findAnonymousComponentTemplate(string $name): ?string - { - if ($component = $this->kit->getComponent($name)) { - foreach ($component->files as $file) { - if (FileType::Twig === $file->type) { - return $file->relativePathName; - } - } - } - - return null; - } - } - ); - $template = $twig->createTemplate(<< @@ -129,24 +96,9 @@ public function findAnonymousComponentTemplate(string $name): ?string HTML); return new Response( - $twig->render($template), + $kitContextRunner->runForKit($kit, fn() => $twig->render($template)), Response::HTTP_OK, ['X-Robots-Tag' => 'noindex, nofollow'] ); } - - /** - * Tweak the ComponentFactory to render anonymous components from the Toolkit kit. - * TODO: In the future, we should implement multiple directories for anonymous components. - */ - private function tweakComponentFactory(ComponentFactory $componentFactory, ComponentTemplateFinderInterface $componentTemplateFinder): void - { - $refl = new \ReflectionClass($componentFactory); - - $propertyConfig = $refl->getProperty('config'); - $propertyConfig->setValue($componentFactory, []); - - $propertyComponentTemplateFinder = $refl->getProperty('componentTemplateFinder'); - $propertyComponentTemplateFinder->setValue($componentFactory, $componentTemplateFinder); - } } From ae50fa4db025ee1e73c5d3cd8d44a913d195160e Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 8 May 2025 16:28:09 +0900 Subject: [PATCH 49/58] [Toolkit] Add functional tests to render all Kit components usage codes (from their documentation), with a snapshot system --- .github/workflows/test.yaml | 6 + src/Toolkit/composer.json | 4 +- src/Toolkit/tests/Fixtures/Kernel.php | 18 ++- .../Functional/ComponentsRenderingTest.php | 119 ++++++++++++++++++ ...it shadcn, component Alert, code 1__1.html | 20 +++ ...it shadcn, component Alert, code 2__1.html | 20 +++ ...it shadcn, component Alert, code 3__1.html | 20 +++ ...dcn, component AspectRatio, code 1__1.html | 17 +++ ...dcn, component AspectRatio, code 2__1.html | 17 +++ ...dcn, component AspectRatio, code 3__1.html | 17 +++ ...t shadcn, component Avatar, code 1__1.html | 13 ++ ...t shadcn, component Avatar, code 2__1.html | 13 ++ ...t shadcn, component Avatar, code 3__1.html | 21 ++++ ...t shadcn, component Avatar, code 4__1.html | 27 ++++ ...it shadcn, component Badge, code 1__1.html | 9 ++ ...it shadcn, component Badge, code 2__1.html | 9 ++ ...it shadcn, component Badge, code 3__1.html | 12 ++ ...it shadcn, component Badge, code 4__1.html | 12 ++ ...it shadcn, component Badge, code 5__1.html | 12 ++ ...it shadcn, component Badge, code 6__1.html | 15 +++ ...adcn, component Breadcrumb, code 1__1.html | 47 +++++++ ...adcn, component Breadcrumb, code 2__1.html | 47 +++++++ ...adcn, component Breadcrumb, code 3__1.html | 56 +++++++++ ... shadcn, component Button, code 10__1.html | 12 ++ ... shadcn, component Button, code 11__1.html | 12 ++ ...t shadcn, component Button, code 1__1.html | 12 ++ ...t shadcn, component Button, code 2__1.html | 12 ++ ...t shadcn, component Button, code 3__1.html | 9 ++ ...t shadcn, component Button, code 4__1.html | 9 ++ ...t shadcn, component Button, code 5__1.html | 9 ++ ...t shadcn, component Button, code 6__1.html | 9 ++ ...t shadcn, component Button, code 7__1.html | 9 ++ ...t shadcn, component Button, code 8__1.html | 9 ++ ...t shadcn, component Button, code 9__1.html | 12 ++ ...Kit shadcn, component Card, code 1__1.html | 33 +++++ ...Kit shadcn, component Card, code 2__1.html | 33 +++++ ...Kit shadcn, component Card, code 3__1.html | 85 +++++++++++++ ...shadcn, component Checkbox, code 1__1.html | 20 +++ ...shadcn, component Checkbox, code 2__1.html | 20 +++ ...shadcn, component Checkbox, code 3__1.html | 16 +++ ...shadcn, component Checkbox, code 4__1.html | 16 +++ ...it shadcn, component Input, code 1__1.html | 9 ++ ...it shadcn, component Input, code 2__1.html | 9 ++ ...it shadcn, component Input, code 3__1.html | 16 +++ ...it shadcn, component Input, code 4__1.html | 9 ++ ...it shadcn, component Input, code 5__1.html | 16 +++ ...it shadcn, component Input, code 6__1.html | 16 +++ ...it shadcn, component Label, code 1__1.html | 16 +++ ...it shadcn, component Label, code 2__1.html | 16 +++ ...it shadcn, component Label, code 3__1.html | 16 +++ ...it shadcn, component Label, code 4__1.html | 16 +++ ...adcn, component Pagination, code 1__1.html | 60 +++++++++ ...adcn, component Pagination, code 2__1.html | 60 +++++++++ ...adcn, component Pagination, code 3__1.html | 82 ++++++++++++ ...shadcn, component Progress, code 1__1.html | 11 ++ ...shadcn, component Progress, code 2__1.html | 11 ++ ...shadcn, component Progress, code 3__1.html | 24 ++++ ...shadcn, component Progress, code 4__1.html | 36 ++++++ ...t shadcn, component Select, code 1__1.html | 16 +++ ...t shadcn, component Select, code 2__1.html | 16 +++ ...t shadcn, component Select, code 3__1.html | 22 ++++ ...t shadcn, component Select, code 4__1.html | 16 +++ ...hadcn, component Separator, code 1__1.html | 45 +++++++ ...hadcn, component Separator, code 2__1.html | 45 +++++++ ...hadcn, component Separator, code 3__1.html | 25 ++++ ...shadcn, component Skeleton, code 1__1.html | 24 ++++ ...shadcn, component Skeleton, code 2__1.html | 24 ++++ ...shadcn, component Skeleton, code 3__1.html | 24 ++++ ...t shadcn, component Switch, code 1__1.html | 19 +++ ...t shadcn, component Switch, code 2__1.html | 19 +++ ...t shadcn, component Switch, code 3__1.html | 53 ++++++++ ...it shadcn, component Table, code 1__1.html | 65 ++++++++++ ...it shadcn, component Table, code 2__1.html | 105 ++++++++++++++++ ...shadcn, component Textarea, code 1__1.html | 9 ++ ...shadcn, component Textarea, code 2__1.html | 9 ++ ...shadcn, component Textarea, code 3__1.html | 16 +++ ...shadcn, component Textarea, code 4__1.html | 9 ++ .../tests/Kit/KitContextRunnerTest.php | 4 +- .../Toolkit/ComponentsController.php | 2 +- 79 files changed, 1873 insertions(+), 5 deletions(-) create mode 100644 src/Toolkit/tests/Functional/ComponentsRenderingTest.php create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html create mode 100644 src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a17aa41e180..13d09fa9af4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -116,6 +116,12 @@ jobs: working-directory: "src/${{ matrix.component }}" dependency-versions: ${{ matrix.dependency-version }} + - name: Install specific packages for Toolkit when PHP >=8.2 + if: ${{ matrix.component == 'Toolkit' && matrix.php-version != '8.1' }} + run: + composer require --dev tales-from-a-dev/twig-tailwind-extra + working-directory: "src/${{ matrix.component }}" + - name: ${{ matrix.component }} Tests working-directory: "src/${{ matrix.component }}" run: vendor/bin/simple-phpunit diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json index dc3a22674a2..5b4046e501d 100644 --- a/src/Toolkit/composer.json +++ b/src/Toolkit/composer.json @@ -45,7 +45,9 @@ "symfony/http-client": "6.4|^7.0", "symfony/stopwatch": "^6.4|^7.0", "symfony/phpunit-bridge": "^6.4|^7.0", - "vincentlanglet/twig-cs-fixer": "^3.5" + "vincentlanglet/twig-cs-fixer": "^3.5", + "spatie/phpunit-snapshot-assertions": "^4.2.17", + "symfony/ux-icons": "^2.18" }, "bin": [ "bin/ux-toolkit-kit-create", diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php index 7ea7eb0574f..3273f2e4c4b 100644 --- a/src/Toolkit/tests/Fixtures/Kernel.php +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -16,8 +16,11 @@ use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\UX\Icons\UXIconsBundle; use Symfony\UX\Toolkit\UXToolkitBundle; use Symfony\UX\TwigComponent\TwigComponentBundle; +use TalesFromADev\Twig\Extra\Tailwind\Bridge\Symfony\Bundle\TalesFromADevTwigExtraTailwindBundle; +use Twig\Extra\TwigExtraBundle\TwigExtraBundle; final class Kernel extends BaseKernel { @@ -25,12 +28,22 @@ final class Kernel extends BaseKernel public function registerBundles(): iterable { - return [ + $bundles = [ new FrameworkBundle(), new TwigBundle(), new TwigComponentBundle(), + new TwigExtraBundle(), + new UXIconsBundle(), new UXToolkitBundle(), ]; + + if (class_exists(TalesFromADevTwigExtraTailwindBundle::class)) { + $bundles[] = new TalesFromADevTwigExtraTailwindBundle(); + } elseif (\PHP_VERSION_ID >= 80200) { + throw new \RuntimeException('The dependency "tales-from-a-dev/twig-tailwind-extra" must be installed when using PHP 8.2+.'); + } + + return $bundles; } protected function configureContainer(ContainerConfigurator $container): void @@ -69,6 +82,9 @@ protected function configureContainer(ContainerConfigurator $container): void ->alias('ux_toolkit.registry.registry_factory', '.ux_toolkit.registry.registry_factory') ->public() + + ->alias('ux_toolkit.registry.local', '.ux_toolkit.registry.local') + ->public() ; } } diff --git a/src/Toolkit/tests/Functional/ComponentsRenderingTest.php b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php new file mode 100644 index 00000000000..8a6fbb57d60 --- /dev/null +++ b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Functional; + +use Spatie\Snapshots\Drivers\HtmlDriver; +use Spatie\Snapshots\MatchesSnapshots; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitFactory; +use Symfony\UX\Toolkit\Registry\LocalRegistry; + +class ComponentsRenderingTest extends WebTestCase +{ + use MatchesSnapshots; + + private const KITS_DIR = __DIR__.'/../../kits'; + + /** + * @return iterable + */ + public static function provideTestComponentRendering(): iterable + { + foreach (LocalRegistry::getAvailableKitsName() as $kitName) { + $kitDir = Path::join(__DIR__, '../../kits', $kitName, 'docs/components'); + $docsFinder = (new Finder())->files()->name('*.md')->in($kitDir)->depth(0); + + foreach ($docsFinder as $docFile) { + $componentName = $docFile->getFilenameWithoutExtension(); + + $codeBlockMatchesResult = preg_match_all('/```twig.*?\n(?P.+?)```/s', $docFile->getContents(), $codeBlockMatches); + if (false === $codeBlockMatchesResult || 0 === $codeBlockMatchesResult) { + throw new \RuntimeException(\sprintf('No Twig code blocks found in file "%s"', $docFile->getRelativePathname())); + } + + foreach ($codeBlockMatches['code'] as $i => $code) { + yield \sprintf('Kit %s, component %s, code #%d', $kitName, $componentName, $i + 1) => [$kitName, $componentName, $code]; + } + } + } + } + + /** + * @dataProvider provideTestComponentRendering + */ + public function testComponentRendering(string $kitName, string $componentName, string $code): void + { + $twig = self::getContainer()->get('twig'); + $kitContextRunner = self::getContainer()->get('ux_toolkit.kit.kit_context_runner'); + + $kit = $this->instantiateKit($kitName); + $template = $twig->createTemplate($code); + + try { + $renderedCode = $kitContextRunner->runForKit($kit, fn () => $template->render()); + } catch (\Twig\Error\SyntaxError $e) { + if (\PHP_VERSION < 80200 && str_contains($e->getMessage(), 'Unknown "tailwind_merge" filter')) { + $this->markTestSkipped('Filter "tailwind_merge" is not supported on PHP <8.2.'); + } + + throw $e; + } + + $this->assertCodeRenderedMatchesHtmlSnapshot($kit, $kit->getComponent($componentName), $code, $renderedCode); + } + + private function instantiateKit(string $kitName): Kit + { + $kitFactory = self::getContainer()->get('ux_toolkit.kit.kit_factory'); + + self::assertInstanceOf(KitFactory::class, $kitFactory); + + return $kitFactory->createKitFromAbsolutePath(Path::join(__DIR__, '../../kits', $kitName)); + } + + private function assertCodeRenderedMatchesHtmlSnapshot(Kit $kit, Component $component, string $code, string $renderedCode): void + { + $info = \sprintf(<< + HTML, + $kit->name, + $component->name, + trim($code) + ); + + $this->assertMatchesSnapshot($renderedCode, new class($info) extends HtmlDriver { + public function __construct(private string $info) + { + } + + public function serialize($data): string + { + $serialized = parent::serialize($data); + $serialized = str_replace(['', ''], '', $serialized); + $serialized = trim($serialized); + + return $this->info."\n".$serialized; + } + }); + } +} diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html new file mode 100644 index 00000000000..c3586e297f7 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html new file mode 100644 index 00000000000..c3586e297f7 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html new file mode 100644 index 00000000000..87ea2e3ad78 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html new file mode 100644 index 00000000000..6f089ea841b --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html @@ -0,0 +1,17 @@ + +
      +Landscape photograph by Tobias Tullius +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html new file mode 100644 index 00000000000..b20d51b0c0c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html @@ -0,0 +1,17 @@ + +
      +Landscape photograph by Tobias Tullius +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html new file mode 100644 index 00000000000..297a8d71d0d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html @@ -0,0 +1,17 @@ + +
      +Landscape photograph by Tobias Tullius +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html new file mode 100644 index 00000000000..e1c7e6eed56 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html @@ -0,0 +1,13 @@ + +@symfony + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html new file mode 100644 index 00000000000..e1c7e6eed56 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html @@ -0,0 +1,13 @@ + +@symfony + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html new file mode 100644 index 00000000000..7ef48de0fd2 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html @@ -0,0 +1,21 @@ + +
      + FP + + FP + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html new file mode 100644 index 00000000000..c16030c2697 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html @@ -0,0 +1,27 @@ + +
      + @symfony + + + FP + + FP + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html new file mode 100644 index 00000000000..7b3a1ebdbad --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html @@ -0,0 +1,9 @@ + +
      Badge
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html new file mode 100644 index 00000000000..7b3a1ebdbad --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html @@ -0,0 +1,9 @@ + +
      Badge
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html new file mode 100644 index 00000000000..a32c304ab18 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html @@ -0,0 +1,12 @@ + +
      Badge +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html new file mode 100644 index 00000000000..18c1b14ff9a --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html @@ -0,0 +1,12 @@ + +
      Badge +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html new file mode 100644 index 00000000000..5cdce6f87c3 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html @@ -0,0 +1,12 @@ + +
      Badge +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html new file mode 100644 index 00000000000..c2dbe83612a --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html @@ -0,0 +1,15 @@ + +
      + + Verified +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html new file mode 100644 index 00000000000..a7cb9999539 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html @@ -0,0 +1,47 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html new file mode 100644 index 00000000000..a7cb9999539 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html @@ -0,0 +1,47 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html new file mode 100644 index 00000000000..d55f2526493 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html @@ -0,0 +1,56 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html new file mode 100644 index 00000000000..cccc55500e1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html new file mode 100644 index 00000000000..3268a717761 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html new file mode 100644 index 00000000000..adc6db76012 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html new file mode 100644 index 00000000000..adc6db76012 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html new file mode 100644 index 00000000000..8d5d34c2f1b --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html new file mode 100644 index 00000000000..51fee13723d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html new file mode 100644 index 00000000000..609ce7dc802 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html new file mode 100644 index 00000000000..51fee13723d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html new file mode 100644 index 00000000000..b787ff13452 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html new file mode 100644 index 00000000000..0bdafc6e3f0 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html new file mode 100644 index 00000000000..331e9217ac5 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html new file mode 100644 index 00000000000..0adf24f033d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html @@ -0,0 +1,33 @@ + +
      +
      +
      Card Title
      +
      Card Description
      +
      +
      +

      Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

      +
      +
      + + +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html new file mode 100644 index 00000000000..0adf24f033d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html @@ -0,0 +1,33 @@ + +
      +
      +
      Card Title
      +
      Card Description
      +
      +
      +

      Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

      +
      +
      + + +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html new file mode 100644 index 00000000000..9171b0e831f --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html @@ -0,0 +1,85 @@ + +
      +
      +
      Notifications
      +
      You have 3 unread messages.
      +
      +
      +
      + +
      +

      + Your call has been confirmed. +

      +

      + 1 hour ago +

      +
      +
      +
      + +
      +

      + You have a new message! +

      +

      + 1 hour ago +

      +
      +
      +
      + +
      +

      + Your subscription is expiring soon! +

      +

      + 2 hours ago +

      +
      +
      +
      +
      + +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html new file mode 100644 index 00000000000..543639dbff1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html @@ -0,0 +1,20 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html new file mode 100644 index 00000000000..543639dbff1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html @@ -0,0 +1,20 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html new file mode 100644 index 00000000000..169aee1851c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html new file mode 100644 index 00000000000..70ca0d838fb --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html new file mode 100644 index 00000000000..1e51cbcdbd4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html new file mode 100644 index 00000000000..1e51cbcdbd4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html new file mode 100644 index 00000000000..426ba17ddf4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html new file mode 100644 index 00000000000..9ef448ac461 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html new file mode 100644 index 00000000000..3ee2727d1b8 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html new file mode 100644 index 00000000000..1811212cd22 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html new file mode 100644 index 00000000000..c085f9dbf88 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html new file mode 100644 index 00000000000..c085f9dbf88 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html new file mode 100644 index 00000000000..2ac19e5f059 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html new file mode 100644 index 00000000000..ccf0432e0ef --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html new file mode 100644 index 00000000000..54da00b18ac --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html @@ -0,0 +1,60 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html new file mode 100644 index 00000000000..54da00b18ac --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html @@ -0,0 +1,60 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html new file mode 100644 index 00000000000..cbdcec499aa --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html @@ -0,0 +1,82 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html new file mode 100644 index 00000000000..0731fbc07ce --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html @@ -0,0 +1,11 @@ + +
      +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html new file mode 100644 index 00000000000..0731fbc07ce --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html @@ -0,0 +1,11 @@ + +
      +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html new file mode 100644 index 00000000000..2fcf5c28d83 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html @@ -0,0 +1,24 @@ + +
      +
      + + 33% +
      +
      +
      +
      + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html new file mode 100644 index 00000000000..0256b1db386 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html @@ -0,0 +1,36 @@ + +
      +
      +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html new file mode 100644 index 00000000000..4d34ca5723c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html new file mode 100644 index 00000000000..4d34ca5723c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html new file mode 100644 index 00000000000..c989fe3eec4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html @@ -0,0 +1,22 @@ + +
      + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html new file mode 100644 index 00000000000..f82b4538605 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html new file mode 100644 index 00000000000..565b8f66e20 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html @@ -0,0 +1,45 @@ + +
      +
      +

      Symfony UX

      +

      + Symfony UX initiative: a JavaScript ecosystem for Symfony +

      +
      +
      +
      + +
      + Website +
      +
      + + Packages +
      +
      + + Source +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html new file mode 100644 index 00000000000..c1a0775bffe --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html @@ -0,0 +1,45 @@ + +
      +
      +

      Symfony UX

      +

      + Symfony UX initiative: a JavaScript ecosystem for Symfony +

      +
      +
      +
      + +
      +
      Blog
      +
      +
      + +
      Docs
      +
      +
      + +
      Source
      +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html new file mode 100644 index 00000000000..dbd412538bf --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html @@ -0,0 +1,25 @@ + +
      +
      Blog
      +
      +
      + +
      Docs
      +
      +
      + +
      Source
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html new file mode 100644 index 00000000000..499706948a1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html @@ -0,0 +1,24 @@ + +
      +
      + +
      +
      + +
      + +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html new file mode 100644 index 00000000000..499706948a1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html @@ -0,0 +1,24 @@ + +
      +
      + +
      +
      + +
      + +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html new file mode 100644 index 00000000000..dd9e56d778d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html @@ -0,0 +1,24 @@ + +
      +
      + +
      +
      + +
      + +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html new file mode 100644 index 00000000000..d8b44e43b80 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html @@ -0,0 +1,19 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html new file mode 100644 index 00000000000..d8b44e43b80 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html @@ -0,0 +1,19 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html new file mode 100644 index 00000000000..69fc94946cc --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html @@ -0,0 +1,53 @@ + +
      +

      Email Notifications

      +
      +
      +
      + +

      Receive emails about new products, features, and more.

      +
      + + +
      +
      +
      + +

      Receive emails about your account security.

      +
      + + +
      +
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html new file mode 100644 index 00000000000..4b75f309575 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html @@ -0,0 +1,65 @@ + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      A list of your recent invoices.
      InvoiceStatusMethodAmount
      INV001PaidCredit Card$250.00
      INV002PendingPayPal$150.00
      INV003UnpaidBank Transfer$350.00
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html new file mode 100644 index 00000000000..b9f6244ffce --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html @@ -0,0 +1,105 @@ + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      A list of your recent invoices.
      InvoiceStatusMethodAmount
      INV001PaidCredit Card$250.00
      INV002PendingPayPal$150.00
      INV003UnpaidBank Transfer$350.00
      INV004PaidCredit Card$450.00
      INV005PaidPayPal$550.00
      INV006PendingBank Transfer$200.00
      INV007UnpaidCredit Card$300.00
      Total$1,500.00
      +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html new file mode 100644 index 00000000000..49c8bdd6848 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html new file mode 100644 index 00000000000..49c8bdd6848 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html new file mode 100644 index 00000000000..61f1f01f337 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html @@ -0,0 +1,16 @@ + +
      + + + +
      \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html new file mode 100644 index 00000000000..ff0e2502d34 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Kit/KitContextRunnerTest.php b/src/Toolkit/tests/Kit/KitContextRunnerTest.php index 3a60afd83d4..7cfe77c4094 100644 --- a/src/Toolkit/tests/Kit/KitContextRunnerTest.php +++ b/src/Toolkit/tests/Kit/KitContextRunnerTest.php @@ -37,12 +37,12 @@ public function testRunForKitShouldConfigureThenResetServices(): void $this->assertNotEquals($initialTwigLoader, $twig->getLoader(), 'The Twig loader must be different in this current kit-aware context.'); $this->assertNotEquals($initialComponentFactoryState, $this->extractComponentFactoryState($componentFactory), 'The ComponentFactory state must be different in this current kit-aware context.'); - $template = $twig->createTemplate('Hello world'); + $template = $twig->createTemplate('Hello world'); $renderedTemplate = $template->render(); $this->assertNotEmpty($renderedTemplate); $this->assertStringContainsString('Hello world', $renderedTemplate); - $this->assertStringContainsString('class="', $renderedTemplate); + $this->assertStringContainsString('style="aspect-ratio:', $renderedTemplate); }); $this->assertTrue($executed, \sprintf('The callback passed to %s::runForKit() has not been executed.', KitContextRunner::class)); diff --git a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php index bcfde7b0d41..f0bb2599916 100644 --- a/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php +++ b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php @@ -96,7 +96,7 @@ public function previewComponent( HTML); return new Response( - $kitContextRunner->runForKit($kit, fn() => $twig->render($template)), + $kitContextRunner->runForKit($kit, fn () => $twig->render($template)), Response::HTTP_OK, ['X-Robots-Tag' => 'noindex, nofollow'] ); From d08014494c6a9d6d4ebe5d7ee446bea948f35ee2 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 8 May 2025 16:31:19 +0900 Subject: [PATCH 50/58] =?UTF-8?q?[Toolkit]=20Remove=20documentation=20abou?= =?UTF-8?q?t=20non-existant=20component=20AlertDialog=20(will=20be=20re-ad?= =?UTF-8?q?ded=20later=20=F0=9F=98=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shadcn/docs/components/AlertDialog.md | 80 ------------------- 1 file changed, 80 deletions(-) delete mode 100644 src/Toolkit/kits/shadcn/docs/components/AlertDialog.md diff --git a/src/Toolkit/kits/shadcn/docs/components/AlertDialog.md b/src/Toolkit/kits/shadcn/docs/components/AlertDialog.md deleted file mode 100644 index 3109e0503ef..00000000000 --- a/src/Toolkit/kits/shadcn/docs/components/AlertDialog.md +++ /dev/null @@ -1,80 +0,0 @@ -# AlertDialog - -A modal dialog that interrupts the user with important content and expects a response. - -```twig {"preview":true} - - - Show Dialog - - - - Are you sure? - - This action cannot be undone. This will permanently delete your account - and remove your data from our servers. - - - - Cancel - Continue - - - -``` - -## Installation - - - -## Usage - - - -## Examples - -### Default - -```twig {"preview":true} - - - Show Dialog - - - - Are you sure? - - This action cannot be undone. This will permanently delete your account - and remove your data from our servers. - - - - Cancel - Continue - - - -``` - -### Destructive - -```twig {"preview":true} - - - Delete Account - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your account - and remove your data from our servers. - - - - Cancel - Delete Account - - - -``` From c4dcce6cc2dbff254311fc9790f07c66834b113e Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 8 May 2025 18:43:00 +0900 Subject: [PATCH 51/58] [Toolkit] Pin symfony/phpunit-bridge to ^7.2 and phpunit to ^9.6.22 --- src/Toolkit/composer.json | 3 ++- src/Toolkit/phpunit.xml.dist | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json index 5b4046e501d..2140216042d 100644 --- a/src/Toolkit/composer.json +++ b/src/Toolkit/composer.json @@ -44,9 +44,10 @@ "zenstruck/console-test": "^1.7", "symfony/http-client": "6.4|^7.0", "symfony/stopwatch": "^6.4|^7.0", - "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/phpunit-bridge": "^7.2", "vincentlanglet/twig-cs-fixer": "^3.5", "spatie/phpunit-snapshot-assertions": "^4.2.17", + "phpunit/phpunit": "^9.6.22", "symfony/ux-icons": "^2.18" }, "bin": [ diff --git a/src/Toolkit/phpunit.xml.dist b/src/Toolkit/phpunit.xml.dist index 0b5ea05456f..0a4c3bed992 100644 --- a/src/Toolkit/phpunit.xml.dist +++ b/src/Toolkit/phpunit.xml.dist @@ -4,7 +4,7 @@ backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php" - failOnRisky="true" + failOnRisky="true" failOnWarning="true" > @@ -12,8 +12,6 @@ - - From 087f09c91041a9b4f6deb4248c50d5f1fedb6929 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 9 May 2025 02:24:17 +0900 Subject: [PATCH 52/58] [Toolkit] Remove checks layers for tales-from-a-dev/twig-tailwind-extra on PHP < 8.2, since it now supports PHP 8.1 --- .github/workflows/test.yaml | 6 ------ src/Toolkit/composer.json | 3 ++- src/Toolkit/tests/Fixtures/Kernel.php | 11 ++--------- .../tests/Functional/ComponentsRenderingTest.php | 11 +---------- 4 files changed, 5 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 13d09fa9af4..a17aa41e180 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -116,12 +116,6 @@ jobs: working-directory: "src/${{ matrix.component }}" dependency-versions: ${{ matrix.dependency-version }} - - name: Install specific packages for Toolkit when PHP >=8.2 - if: ${{ matrix.component == 'Toolkit' && matrix.php-version != '8.1' }} - run: - composer require --dev tales-from-a-dev/twig-tailwind-extra - working-directory: "src/${{ matrix.component }}" - - name: ${{ matrix.component }} Tests working-directory: "src/${{ matrix.component }}" run: vendor/bin/simple-phpunit diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json index 2140216042d..dc0e699dbc4 100644 --- a/src/Toolkit/composer.json +++ b/src/Toolkit/composer.json @@ -48,7 +48,8 @@ "vincentlanglet/twig-cs-fixer": "^3.5", "spatie/phpunit-snapshot-assertions": "^4.2.17", "phpunit/phpunit": "^9.6.22", - "symfony/ux-icons": "^2.18" + "symfony/ux-icons": "^2.18", + "tales-from-a-dev/twig-tailwind-extra": "^0.4.0" }, "bin": [ "bin/ux-toolkit-kit-create", diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php index 3273f2e4c4b..6734bb54ea5 100644 --- a/src/Toolkit/tests/Fixtures/Kernel.php +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -28,22 +28,15 @@ final class Kernel extends BaseKernel public function registerBundles(): iterable { - $bundles = [ + return [ new FrameworkBundle(), new TwigBundle(), new TwigComponentBundle(), new TwigExtraBundle(), new UXIconsBundle(), + new TalesFromADevTwigExtraTailwindBundle(), new UXToolkitBundle(), ]; - - if (class_exists(TalesFromADevTwigExtraTailwindBundle::class)) { - $bundles[] = new TalesFromADevTwigExtraTailwindBundle(); - } elseif (\PHP_VERSION_ID >= 80200) { - throw new \RuntimeException('The dependency "tales-from-a-dev/twig-tailwind-extra" must be installed when using PHP 8.2+.'); - } - - return $bundles; } protected function configureContainer(ContainerConfigurator $container): void diff --git a/src/Toolkit/tests/Functional/ComponentsRenderingTest.php b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php index 8a6fbb57d60..39978c8a5cd 100644 --- a/src/Toolkit/tests/Functional/ComponentsRenderingTest.php +++ b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php @@ -61,16 +61,7 @@ public function testComponentRendering(string $kitName, string $componentName, s $kit = $this->instantiateKit($kitName); $template = $twig->createTemplate($code); - - try { - $renderedCode = $kitContextRunner->runForKit($kit, fn () => $template->render()); - } catch (\Twig\Error\SyntaxError $e) { - if (\PHP_VERSION < 80200 && str_contains($e->getMessage(), 'Unknown "tailwind_merge" filter')) { - $this->markTestSkipped('Filter "tailwind_merge" is not supported on PHP <8.2.'); - } - - throw $e; - } + $renderedCode = $kitContextRunner->runForKit($kit, fn () => $template->render()); $this->assertCodeRenderedMatchesHtmlSnapshot($kit, $kit->getComponent($componentName), $code, $renderedCode); } From 128299528fb0c1c607268ff8a5775a3b2305cd13 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sun, 11 May 2025 18:13:47 +0900 Subject: [PATCH 53/58] [Toolkit] Minor adjustements on Kit creation command (reword questions, fix doc format) --- src/Toolkit/src/Command/CreateKitCommand.php | 49 +++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/Toolkit/src/Command/CreateKitCommand.php b/src/Toolkit/src/Command/CreateKitCommand.php index ddbe6ff2920..604a88c0265 100644 --- a/src/Toolkit/src/Command/CreateKitCommand.php +++ b/src/Toolkit/src/Command/CreateKitCommand.php @@ -43,10 +43,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); // Get the kit name - $question = new Question('What is the name of your kit?'); + $question = new Question("What's the name of your kit?"); $question->setValidator(function (?string $value) { if (empty($value)) { - throw new \RuntimeException('Kit name cannot be empty'); + throw new \RuntimeException('Kit name cannot be empty.'); } Assert::kitName($value); @@ -55,10 +55,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $kitName = $io->askQuestion($question); // Get the kit homepage - $question = new Question('What is the homepage of your kit?'); + $question = new Question("What's the Homepage URL of your kit?"); $question->setValidator(function (?string $value) { if (empty($value) || !filter_var($value, \FILTER_VALIDATE_URL)) { - throw new \Exception('The homepage must be a valid URL'); + throw new \Exception('The homepage URL must be valid.'); } return $value; @@ -66,10 +66,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $kitHomepage = $io->askQuestion($question); // Get the kit author name - $question = new Question('What is the author name of your kit?'); + $question = new Question("What's the name of the author?"); $question->setValidator(function (?string $value) { if (empty($value)) { - throw new \Exception('The author name cannot be empty'); + throw new \Exception('The author name cannot be empty.'); } return $value; @@ -80,7 +80,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $question = new Question('What is the license of your kit?'); $question->setValidator(function (string $value) { if (empty($value)) { - throw new \Exception('The license cannot be empty'); + throw new \Exception('The license cannot be empty.'); } return $value; @@ -107,20 +107,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int ) -%} TWIG ); - $this->filesystem->dumpFile('docs/components/Button.twig', <<filesystem->dumpFile('docs/components/Button.md', << @@ -128,17 +139,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int ``` -# Button with Variants +## Examples + +### Button with Variants ```twig Default Secondary ``` -{% endblock %} + TWIG ); - $io->success('Perfect, you can now start building your kit!'); + $io->success('Your kit has been scaffolded, enjoy!'); return self::SUCCESS; } From 05726a60105e22e3170922e057f6ee7e20eb8fea Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sun, 11 May 2025 18:24:52 +0900 Subject: [PATCH 54/58] [Toolkit] Remove Kit "authors" --- src/Toolkit/kits/shadcn/manifest.json | 1 - src/Toolkit/src/Command/CreateKitCommand.php | 12 ------------ src/Toolkit/src/Command/DebugKitCommand.php | 1 - src/Toolkit/src/Kit/Kit.php | 14 ++++++-------- src/Toolkit/src/Kit/KitFactory.php | 1 - src/Toolkit/tests/Command/DebugKitCommandTest.php | 1 - .../manifest.json | 3 +-- .../kits/with-stimulus-controllers/manifest.json | 3 +-- src/Toolkit/tests/Kit/KitTest.php | 12 ++++++------ 9 files changed, 14 insertions(+), 34 deletions(-) diff --git a/src/Toolkit/kits/shadcn/manifest.json b/src/Toolkit/kits/shadcn/manifest.json index 6a39b488b8d..0c8a4c4d353 100644 --- a/src/Toolkit/kits/shadcn/manifest.json +++ b/src/Toolkit/kits/shadcn/manifest.json @@ -3,6 +3,5 @@ "description": "Component based on the Shadcn UI library, one of the most popular design systems in JavaScript world.", "license": "MIT", "homepage": "https://ux.symfony.com/components", - "authors": ["Shadcn", "Symfony Community"], "ux-icon": "simple-icons:shadcnui" } diff --git a/src/Toolkit/src/Command/CreateKitCommand.php b/src/Toolkit/src/Command/CreateKitCommand.php index 604a88c0265..42f86014240 100644 --- a/src/Toolkit/src/Command/CreateKitCommand.php +++ b/src/Toolkit/src/Command/CreateKitCommand.php @@ -65,17 +65,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int }); $kitHomepage = $io->askQuestion($question); - // Get the kit author name - $question = new Question("What's the name of the author?"); - $question->setValidator(function (?string $value) { - if (empty($value)) { - throw new \Exception('The author name cannot be empty.'); - } - - return $value; - }); - $kitAuthorName = $io->askQuestion($question); - // Get the kit license $question = new Question('What is the license of your kit?'); $question->setValidator(function (string $value) { @@ -91,7 +80,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->filesystem->dumpFile('manifest.json', json_encode([ 'name' => $kitName, 'homepage' => $kitHomepage, - 'authors' => [$kitAuthorName], 'license' => $kitLicense, ], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); $this->filesystem->dumpFile('templates/components/Button.html.twig', <<definitionList( ['Name' => $kit->name], ['Homepage' => $kit->homepage], - ['Authors' => implode(', ', $kit->authors)], ['License' => $kit->license], new TableSeparator(), ['Path' => $kit->path], diff --git a/src/Toolkit/src/Kit/Kit.php b/src/Toolkit/src/Kit/Kit.php index 542d6b759d2..dcf69290581 100644 --- a/src/Toolkit/src/Kit/Kit.php +++ b/src/Toolkit/src/Kit/Kit.php @@ -24,19 +24,17 @@ final class Kit { /** - * @param non-empty-string $path - * @param non-empty-string $name - * @param non-empty-string|null $homepage - * @param list|null $authors - * @param non-empty-string|null $license - * @param list $components - * @param list $stimulusControllers + * @param non-empty-string $path + * @param non-empty-string $name + * @param non-empty-string|null $homepage + * @param non-empty-string|null $license + * @param list $components + * @param list $stimulusControllers */ public function __construct( public readonly string $path, public readonly string $name, public readonly ?string $homepage = null, - public readonly array $authors = [], public readonly ?string $license = null, public readonly ?string $description = null, public readonly ?string $uxIcon = null, diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php index 6a1f5432b22..039258c2077 100644 --- a/src/Toolkit/src/Kit/KitFactory.php +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -51,7 +51,6 @@ public function createKitFromAbsolutePath(string $absolutePath): Kit path: $absolutePath, name: $manifest['name'] ?? throw new \InvalidArgumentException('Manifest file is missing "name" key.'), homepage: $manifest['homepage'] ?? throw new \InvalidArgumentException('Manifest file is missing "homepage" key.'), - authors: $manifest['authors'] ?? throw new \InvalidArgumentException('Manifest file is missing "authors" key.'), license: $manifest['license'] ?? throw new \InvalidArgumentException('Manifest file is missing "license" key.'), description: $manifest['description'] ?? null, uxIcon: $manifest['ux-icon'] ?? null, diff --git a/src/Toolkit/tests/Command/DebugKitCommandTest.php b/src/Toolkit/tests/Command/DebugKitCommandTest.php index 4704b55df1d..e3f9d61e181 100644 --- a/src/Toolkit/tests/Command/DebugKitCommandTest.php +++ b/src/Toolkit/tests/Command/DebugKitCommandTest.php @@ -27,7 +27,6 @@ public function testShouldBeAbleToDebug(): void // Kit details ->assertOutputContains('Name Shadcn') ->assertOutputContains('Homepage https://ux.symfony.com/components') - ->assertOutputContains('Authors Shadcn, Symfony Community') ->assertOutputContains('License MIT') // A component details ->assertOutputContains(<<<'EOF' diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json index 5a1be5a1daa..f23837787ff 100644 --- a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json @@ -2,6 +2,5 @@ "name": "With Circular Components Dependencies", "description": "Kit used as a test fixture.", "license": "MIT", - "homepage": "https://ux.symfony.com/", - "authors": ["Symfony UX Community"] + "homepage": "https://ux.symfony.com/" } diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json index f8f4ff66455..4589ccfdc23 100644 --- a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json @@ -2,6 +2,5 @@ "name": "With Stimulus Controllers", "description": "Kit used as a test fixture.", "license": "MIT", - "homepage": "https://ux.symfony.com/", - "authors": ["Symfony UX Community"] + "homepage": "https://ux.symfony.com/" } diff --git a/src/Toolkit/tests/Kit/KitTest.php b/src/Toolkit/tests/Kit/KitTest.php index f9294020e3b..1696ba39c44 100644 --- a/src/Toolkit/tests/Kit/KitTest.php +++ b/src/Toolkit/tests/Kit/KitTest.php @@ -24,7 +24,7 @@ public function testShouldFailIfKitNameIsInvalid(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid kit name "-foobar".'); - new Kit(__DIR__, '-foobar', 'https://example.com', [], 'MIT'); + new Kit(__DIR__, '-foobar', 'https://example.com', 'MIT'); } public function testShouldFailIfKitPathIsNotAbsolute(): void @@ -32,12 +32,12 @@ public function testShouldFailIfKitPathIsNotAbsolute(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage(\sprintf('Kit path "./%s" is not absolute.', __DIR__)); - new Kit(\sprintf('./%s', __DIR__), 'foo', 'https://example.com', [], 'MIT'); + new Kit(\sprintf('./%s', __DIR__), 'foo', 'https://example.com', 'MIT'); } public function testCanAddComponentsToTheKit(): void { - $kit = new Kit(__DIR__, 'foo', 'https://example.com', [], 'MIT'); + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); $kit->addComponent(new Component('Table', [new File(FileType::Twig, 'Table.html.twig', 'Table.html.twig')], null)); $kit->addComponent(new Component('Table:Row', [new File(FileType::Twig, 'Table/Row.html.twig', 'Table/Row.html.twig')], null)); @@ -49,14 +49,14 @@ public function testShouldFailIfComponentIsAlreadyRegisteredInTheKit(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Component "Table" is already registered in the kit.'); - $kit = new Kit(__DIR__, 'foo', 'https://example.com', [], 'MIT'); + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); $kit->addComponent(new Component('Table', [new File(FileType::Twig, 'Table.html.twig', 'Table.html.twig')], null)); $kit->addComponent(new Component('Table', [new File(FileType::Twig, 'Table.html.twig', 'Table.html.twig')], null)); } public function testCanGetComponentByName(): void { - $kit = new Kit(__DIR__, 'foo', 'https://example.com', [], 'MIT'); + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); $kit->addComponent(new Component('Table', [new File(FileType::Twig, 'Table.html.twig', 'Table.html.twig')], null)); $kit->addComponent(new Component('Table:Row', [new File(FileType::Twig, 'Table/Row.html.twig', 'Table/Row.html.twig')], null)); @@ -66,7 +66,7 @@ public function testCanGetComponentByName(): void public function testShouldReturnNullIfComponentIsNotFound(): void { - $kit = new Kit(__DIR__, 'foo', 'https://example.com', [], 'MIT'); + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); $this->assertNull($kit->getComponent('Table:Cell')); } From 7591e983f23e3692f8d2d5b7f6b6a72a8ce33e53 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sun, 11 May 2025 11:41:39 +0900 Subject: [PATCH 55/58] [Toolkit] Improve descriptions of Shadcn components --- .../kits/shadcn/docs/components/Alert.md | 4 ++-- .../shadcn/docs/components/AspectRatio.md | 14 ++++++------- .../kits/shadcn/docs/components/Avatar.md | 4 ++-- .../kits/shadcn/docs/components/Badge.md | 4 ++-- .../kits/shadcn/docs/components/Breadcrumb.md | 2 +- .../kits/shadcn/docs/components/Button.md | 20 +++++++++---------- .../kits/shadcn/docs/components/Card.md | 8 ++++---- .../kits/shadcn/docs/components/Input.md | 2 +- .../kits/shadcn/docs/components/Label.md | 2 +- .../kits/shadcn/docs/components/Pagination.md | 2 +- .../kits/shadcn/docs/components/Progress.md | 2 +- .../kits/shadcn/docs/components/Select.md | 2 +- .../kits/shadcn/docs/components/Separator.md | 2 +- .../kits/shadcn/docs/components/Skeleton.md | 2 +- .../kits/shadcn/docs/components/Switch.md | 2 +- .../kits/shadcn/docs/components/Table.md | 4 ++-- .../kits/shadcn/docs/components/Textarea.md | 2 +- 17 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/Toolkit/kits/shadcn/docs/components/Alert.md b/src/Toolkit/kits/shadcn/docs/components/Alert.md index 14de9f32083..5317941445c 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Alert.md +++ b/src/Toolkit/kits/shadcn/docs/components/Alert.md @@ -1,6 +1,6 @@ # Alert -Displays a callout for user attention. +A notification component that displays important messages with an icon, title, and description. ```twig {"preview":true} @@ -44,4 +44,4 @@ Displays a callout for user attention. Your session has expired. Please log in again. -``` +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md b/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md index 43128d8604b..4c8e2d32865 100644 --- a/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md +++ b/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md @@ -1,11 +1,11 @@ # AspectRatio -The AspectRatio component is a component that allows you to display an element with a specific aspect ratio. +A container that maintains a specific width-to-height ratio for its content. ```twig {"preview":true,"height":"400px"} - Landscape photograph by Tobias Tullius @@ -26,8 +26,8 @@ The AspectRatio component is a component that allows you to display an element w ```twig {"preview":true,"height":"400px"} - Landscape photograph by Tobias Tullius @@ -38,8 +38,8 @@ The AspectRatio component is a component that allows you to display an element w ```twig {"preview":true,"height":"400px"} - Landscape photograph by Tobias Tullius diff --git a/src/Toolkit/kits/shadcn/docs/components/Avatar.md b/src/Toolkit/kits/shadcn/docs/components/Avatar.md index 08b085b3533..022831b0ba9 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Avatar.md +++ b/src/Toolkit/kits/shadcn/docs/components/Avatar.md @@ -1,6 +1,6 @@ # Avatar -A component for displaying user profile images with a fallback for when the image is not available. +A circular element that displays a user's profile image or initials as a fallback. ```twig {"preview":true} @@ -53,4 +53,4 @@ A component for displaying user profile images with a fallback for when the imag FP -``` +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Badge.md b/src/Toolkit/kits/shadcn/docs/components/Badge.md index 492401d192b..68c13efb739 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Badge.md +++ b/src/Toolkit/kits/shadcn/docs/components/Badge.md @@ -1,6 +1,6 @@ # Badge -A component for displaying short pieces of information, such as counts, labels, or status indicators. +A small element that displays status, counts, or labels with optional icons. ```twig {"preview":true} Badge @@ -53,4 +53,4 @@ A component for displaying short pieces of information, such as counts, labels, Verified -``` +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md index 7b720e9af7f..680e7b8558f 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md +++ b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md @@ -1,6 +1,6 @@ # Breadcrumb -A navigation component that displays the current page's location within a website's hierarchy. +A navigation element that shows the current page's location in the site hierarchy with clickable links. ```twig {"preview":true} diff --git a/src/Toolkit/kits/shadcn/docs/components/Button.md b/src/Toolkit/kits/shadcn/docs/components/Button.md index 82a7c655824..8dd9530b0ce 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Button.md +++ b/src/Toolkit/kits/shadcn/docs/components/Button.md @@ -1,6 +1,6 @@ # Button -A button component that can be used to trigger actions or events. +A clickable element that triggers actions or events, supporting various styles and states. ```twig {"preview":true} @@ -30,37 +30,37 @@ A button component that can be used to trigger actions or events. ```twig {"preview":true} Button -``` +``` ### Secondary ```twig {"preview":true} Outline -``` +``` ### Destructive ```twig {"preview":true} Destructive -``` +``` ### Outline ```twig {"preview":true} Outline -``` +``` ### Ghost ```twig {"preview":true} Ghost -``` +``` ### Link ```twig {"preview":true} Link -``` +``` ### Icon @@ -68,7 +68,7 @@ A button component that can be used to trigger actions or events. -``` +``` ### With Icon @@ -76,7 +76,7 @@ A button component that can be used to trigger actions or events. Login with Email -``` +``` ### Loading @@ -84,4 +84,4 @@ A button component that can be used to trigger actions or events. Please wait -``` +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Card.md b/src/Toolkit/kits/shadcn/docs/components/Card.md index 3c6bd140da6..f482b75f2e4 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Card.md +++ b/src/Toolkit/kits/shadcn/docs/components/Card.md @@ -1,6 +1,6 @@ # Card -A container component for displaying content in a clear, structured format with optional header, content, and footer sections. +A container that groups related content and actions into a box with optional header, content, and footer sections. ```twig {"preview":true,"height":"300px"} @@ -71,14 +71,14 @@ A container component for displaying content in a clear, structured format with {{ notification.description }}

      - + {%- endfor -%} - + Mark all as read
      -``` +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Input.md b/src/Toolkit/kits/shadcn/docs/components/Input.md index 8a01c3c4ead..de0b816a9d6 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Input.md +++ b/src/Toolkit/kits/shadcn/docs/components/Input.md @@ -1,6 +1,6 @@ # Input -A form input component for text, email, password, and other input types. +A form control that allows users to enter text, numbers, or select files. ```twig {"preview":true} diff --git a/src/Toolkit/kits/shadcn/docs/components/Label.md b/src/Toolkit/kits/shadcn/docs/components/Label.md index 5280ab98666..ea41f854987 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Label.md +++ b/src/Toolkit/kits/shadcn/docs/components/Label.md @@ -1,6 +1,6 @@ # Label -A component for labeling form elements and other content. +A text element that identifies form controls and other content. ```twig {"preview":true}
      diff --git a/src/Toolkit/kits/shadcn/docs/components/Pagination.md b/src/Toolkit/kits/shadcn/docs/components/Pagination.md index 71135b10f00..0efbea5159f 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Pagination.md +++ b/src/Toolkit/kits/shadcn/docs/components/Pagination.md @@ -1,6 +1,6 @@ # Pagination -A component for navigating through paginated content with page numbers and navigation controls. +A navigation component that displays page numbers and controls for moving between pages. ```twig {"preview":true} diff --git a/src/Toolkit/kits/shadcn/docs/components/Progress.md b/src/Toolkit/kits/shadcn/docs/components/Progress.md index aa769256a6c..c2c89234a73 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Progress.md +++ b/src/Toolkit/kits/shadcn/docs/components/Progress.md @@ -1,6 +1,6 @@ # Progress -A component for displaying progress of a task or operation. +A visual indicator that shows the completion status of a task or operation. ```twig {"preview":true} diff --git a/src/Toolkit/kits/shadcn/docs/components/Select.md b/src/Toolkit/kits/shadcn/docs/components/Select.md index 7fe10102b93..91941c0fafe 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Select.md +++ b/src/Toolkit/kits/shadcn/docs/components/Select.md @@ -1,6 +1,6 @@ # Select -A form component for selecting an option from a dropdown list. +A dropdown control that allows users to choose from a list of options. ```twig {"preview":true} diff --git a/src/Toolkit/kits/shadcn/docs/components/Separator.md b/src/Toolkit/kits/shadcn/docs/components/Separator.md index 3117d58b272..038197b45df 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Separator.md +++ b/src/Toolkit/kits/shadcn/docs/components/Separator.md @@ -1,6 +1,6 @@ # Separator -A component for creating visual separators between content. +A visual divider that creates space between content elements, available in horizontal and vertical orientations. ```twig {"preview":true}
      diff --git a/src/Toolkit/kits/shadcn/docs/components/Skeleton.md b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md index b01bf5f940c..4c16a2c64bc 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Skeleton.md +++ b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md @@ -1,6 +1,6 @@ # Skeleton -A component for displaying a loading state with a placeholder animation. +A placeholder element that displays a loading state with an animated background. ```twig {"preview":true}
      diff --git a/src/Toolkit/kits/shadcn/docs/components/Switch.md b/src/Toolkit/kits/shadcn/docs/components/Switch.md index e143138ae94..17fa899f49f 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Switch.md +++ b/src/Toolkit/kits/shadcn/docs/components/Switch.md @@ -1,6 +1,6 @@ # Switch -A toggle switch component for boolean input. +A toggle control that switches between on and off states. ```twig {"preview":true}
      diff --git a/src/Toolkit/kits/shadcn/docs/components/Table.md b/src/Toolkit/kits/shadcn/docs/components/Table.md index 24c8b16cd34..302d6bc91e2 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Table.md +++ b/src/Toolkit/kits/shadcn/docs/components/Table.md @@ -1,6 +1,6 @@ # Table -A component for displaying structured data in rows and columns with support for headers, captions, and customizable styling. +A structured grid element that organizes data into rows and columns, supporting headers, captions, and footers. ```twig {"preview":true,"height":"400px"} {%- set invoices = [ @@ -80,4 +80,4 @@ A component for displaying structured data in rows and columns with support for -``` +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Textarea.md b/src/Toolkit/kits/shadcn/docs/components/Textarea.md index bf6a752d439..b9babc362c9 100644 --- a/src/Toolkit/kits/shadcn/docs/components/Textarea.md +++ b/src/Toolkit/kits/shadcn/docs/components/Textarea.md @@ -1,6 +1,6 @@ # Textarea -A multi-line text input component for longer text content. +A form control for entering multiple lines of text. ```twig {"preview":true} From fc9d51e2d8d910828bb541419c497e5814bfa111 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Tue, 13 May 2025 11:31:33 +0200 Subject: [PATCH 56/58] fix: allow LiveComponentHydrator::hydrateValue() to hydrate null values --- src/LiveComponent/CHANGELOG.md | 1 + .../src/LiveComponentHydrator.php | 4 +++ .../tests/Unit/LiveComponentHydratorTest.php | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 6368ac7b4ed..05a7f92aea0 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -4,6 +4,7 @@ - Add support for [Symfony UID](https://symfony.com/doc/current/components/uid.html) hydration/dehydration - `ComponentWithFormTrait` now correctly checks for a `TranslatableInterface` placeholder for `