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/.github/workflows/release-on-npm.yaml b/.github/workflows/release-on-npm.yaml index 2210a69917c..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: @@ -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: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5ad2e12fe0c..a17aa41e180 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 @@ -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/.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/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md index 8720236c2bb..03022e74226 100644 --- a/src/Autocomplete/CHANGELOG.md +++ b/src/Autocomplete/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.25.0 + +- Escape `querySelector` dynamic selector with `CSS.escape()` #2663 + ## 2.23.0 - Deprecate `ExtraLazyChoiceLoader` in favor of `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader` 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/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index e021770d8b5..b9712824880 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; @@ -270,7 +270,7 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def let orderedOption = null; for (const [, tomSelectOption] of Object.entries(this.tomSelect.options)) { if (tomSelectOption.$order === optionOrder) { - orderedOption = parentElement.querySelector(`:scope > option[value="${tomSelectOption[this.tomSelect.settings.valueField]}"]`); + orderedOption = parentElement.querySelector(`:scope > option[value="${CSS.escape(tomSelectOption[this.tomSelect.settings.valueField])}"]`); break; } } 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/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index 6b68283b51d..f89b29141c4 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -192,7 +192,7 @@ export default class extends Controller { for (const [, tomSelectOption] of Object.entries(this.tomSelect.options)) { if (tomSelectOption.$order === optionOrder) { orderedOption = parentElement.querySelector( - `:scope > option[value="${tomSelectOption[this.tomSelect.settings.valueField]}"]` + `:scope > option[value="${CSS.escape(tomSelectOption[this.tomSelect.settings.valueField])}"]` ); break; 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/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/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/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/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/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/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md index 8ff2aad1fb0..52b4fb26f97 100644 --- a/src/Icons/CHANGELOG.md +++ b/src/Icons/CHANGELOG.md @@ -1,5 +1,15 @@ # 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. +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/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 a20222fd244..607e0160354 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 @@ -277,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 --------------- @@ -463,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 fcbebe81031..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; /** @@ -26,6 +27,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= @@ -38,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); } @@ -61,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)); @@ -89,6 +86,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), ]); } @@ -110,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', ], @@ -136,6 +134,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), ]); } @@ -155,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, @@ -202,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/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..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' => [], ]), @@ -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 @@ -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' => [], ]), @@ -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 @@ -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' => [], ]), 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/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/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 7ba0351676c..05a7f92aea0 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.25.0 + +- Add support for [Symfony UID](https://symfony.com/doc/current/components/uid.html) hydration/dehydration +- `ComponentWithFormTrait` now correctly checks for a `TranslatableInterface` placeholder for ` (not a radio/checkbox) && !$child->vars['multiple'] // is not multiple - && !\is_string($child->vars['placeholder']) // has no placeholder (empty string is valid) + && !\is_string($child->vars['placeholder']) // has no placeholder (empty string is valid) + && !$child->vars['placeholder'] instanceof TranslatableInterface // has no placeholder (translatable interface is valid) ) { $choices = $child->vars['preferred_choices'] ?: $child->vars['choices']; // preferred_choices has precedence, as they rendered before regular choices do { diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index 95f4bf91f9c..1a1b01fa72f 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; @@ -281,6 +282,10 @@ public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, objec throw new \LogicException(\sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName())); } + if (null === $value && $propMetadata->allowsNull()) { + return null; + } + return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext()); } @@ -505,6 +510,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 +562,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/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/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 diff --git a/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php index f14ff4a7b72..594e8daecb7 100644 --- a/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php @@ -14,8 +14,12 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; final class LiveComponentHydratorTest extends TestCase { @@ -32,4 +36,30 @@ public function testConstructWithEmptySecret(): void '', ); } + + public function testItCanHydrateWithNullValues() + { + $hydrator = new LiveComponentHydrator( + [], + $this->createMock(PropertyAccessorInterface::class), + $this->createMock(LiveComponentMetadataFactory::class), + new Serializer(normalizers: [new ObjectNormalizer()]), + 'foo', + ); + + $hydratedValue = $hydrator->hydrateValue( + null, + new LivePropMetadata('foo', new LiveProp(useSerializerForHydration: true), typeName: Foo::class, isBuiltIn: false, allowsNull: true, collectionValueType: null), + parentObject: new \stdClass() // not relevant in this test + ); + + self::assertNull($hydratedValue); + } +} + +class Foo +{ + public function __construct(private int $id) + { + } } 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); diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index 1ab7242f78c..dfb22e55ceb 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -1,9 +1,17 @@ # 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. - 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 +20,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 @@ -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/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/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/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/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/Google/composer.json b/src/Map/src/Bridge/Google/composer.json index 9ae81496e79..7889f360787 100644 --- a/src/Map/src/Bridge/Google/composer.json +++ b/src/Map/src/Bridge/Google/composer.json @@ -16,13 +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/ux-icons": "^2.18" + "symfony/phpunit-bridge": "^7.2", + "symfony/ux-icons": "^2.18", + "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 1c3807e6255..751314ecb8e 100644 --- a/src/Map/src/Bridge/Google/phpunit.xml.dist +++ b/src/Map/src/Bridge/Google/phpunit.xml.dist @@ -12,7 +12,7 @@ ./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/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/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/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/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/Map/src/Bridge/Leaflet/composer.json b/src/Map/src/Bridge/Leaflet/composer.json index 32fc6619d63..f33e00cd48a 100644 --- a/src/Map/src/Bridge/Leaflet/composer.json +++ b/src/Map/src/Bridge/Leaflet/composer.json @@ -16,13 +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/ux-icons": "^2.18" + "symfony/phpunit-bridge": "^7.2", + "symfony/ux-icons": "^2.18", + "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 1c3807e6255..751314ecb8e 100644 --- a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist +++ b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist @@ -12,7 +12,7 @@ ./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/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/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/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; } } 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( '
    ', 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/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/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/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/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); } }); } 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/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/README.md b/src/StimulusBundle/assets/README.md index f6f94d0ff4c..41bdf682ba6 100644 --- a/src/StimulusBundle/assets/README.md +++ b/src/StimulusBundle/assets/README.md @@ -2,9 +2,10 @@ JavaScript assets of the [symfony/stimulus-bundle](https://packagist.org/packages/symfony/stimulus-bundle) PHP package. -This package is private and is not intended to be published on npm or installed. +## Installation -If you're looking for a npm package to integrate Stimulus in your Symfony application, you may be looking for the [`@symfony/stimulus-bridge`](https://www.npmjs.com/package/@symfony/stimulus-bridge) package. +Due to compatibility issues with JSDelivr causing the package not to work as expected, the package is not yet released on NPM. +Read more at [symfony/ux#2708](https://github.com/symfony/ux/issues/2708). ## Resources diff --git a/src/StimulusBundle/assets/package.json b/src/StimulusBundle/assets/package.json index fb2b4d54e21..d4f288fa1a0 100644 --- a/src/StimulusBundle/assets/package.json +++ b/src/StimulusBundle/assets/package.json @@ -1,8 +1,9 @@ { "name": "@symfony/stimulus-bundle", "description": "Integration of @hotwired/stimulus into Symfony", + "private": true, "license": "MIT", - "version": "2.23.0", + "version": "2.24.0", "keywords": [ "symfony-ux" ], 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/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/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}
    +
    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/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/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/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/Toolkit/.gitattributes b/src/Toolkit/.gitattributes new file mode 100644 index 00000000000..81d9dbfaa9e --- /dev/null +++ b/src/Toolkit/.gitattributes @@ -0,0 +1,5 @@ +/.git* export-ignore +/.symfony.bundle.yaml export-ignore +/phpunit.xml.dist export-ignore +/doc export-ignore +/tests export-ignore diff --git a/src/Toolkit/.github/PULL_REQUEST_TEMPLATE.md b/src/Toolkit/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Toolkit/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Toolkit/.github/workflows/close-pull-request.yml b/src/Toolkit/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Toolkit/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Toolkit/.gitignore b/src/Toolkit/.gitignore new file mode 100644 index 00000000000..cf8b688b719 --- /dev/null +++ b/src/Toolkit/.gitignore @@ -0,0 +1,7 @@ +vendor +composer.lock +.phpunit.result.cache +var +.twig-cs-fixer.cache +tests/ui/output +tests/ui/screens diff --git a/src/Toolkit/.symfony.bundle.yaml b/src/Toolkit/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Toolkit/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Toolkit/CHANGELOG.md b/src/Toolkit/CHANGELOG.md new file mode 100644 index 00000000000..f19dba21ca7 --- /dev/null +++ b/src/Toolkit/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.25 + +- Package added diff --git a/src/Toolkit/LICENSE b/src/Toolkit/LICENSE new file mode 100644 index 00000000000..bc38d714ef6 --- /dev/null +++ b/src/Toolkit/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Toolkit/README.md b/src/Toolkit/README.md new file mode 100644 index 00000000000..bb6e5d0d96b --- /dev/null +++ b/src/Toolkit/README.md @@ -0,0 +1,16 @@ +# Symfony UX Toolkit + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Symfony UX Toolkit provides a set of ready-to-use UI components for Symfony applications. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-toolkit/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Toolkit/bin/ux-toolkit-kit-create b/src/Toolkit/bin/ux-toolkit-kit-create new file mode 100755 index 00000000000..6b08db63583 --- /dev/null +++ b/src/Toolkit/bin/ux-toolkit-kit-create @@ -0,0 +1,45 @@ +#!/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\UX\Toolkit\Command\CreateKitCommand; + +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(); + +(new Application())->add($command = new CreateKitCommand($filesystem)) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/src/Toolkit/bin/ux-toolkit-kit-debug b/src/Toolkit/bin/ux-toolkit-kit-debug new file mode 100755 index 00000000000..f10d9edc0f1 --- /dev/null +++ b/src/Toolkit/bin/ux-toolkit-kit-debug @@ -0,0 +1,55 @@ +#!/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\DebugKitCommand; +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)); + +(new Application())->add($command = new DebugKitCommand($kitFactory)) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json new file mode 100644 index 00000000000..dc0e699dbc4 --- /dev/null +++ b/src/Toolkit/composer.json @@ -0,0 +1,78 @@ +{ + "name": "symfony/ux-toolkit", + "type": "symfony-bundle", + "description": "A tool to easily create a design system in your Symfony app with customizable, well-crafted Twig components", + "keywords": [ + "symfony-ux", + "twig", + "components" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Jean-François Lépine", + "email": "lepinejeanfrancois@gmail.com" + }, + { + "name": "Simon André", + "email": "smn.andre@gmail.com" + } + ], + "require": { + "php": ">=8.1", + "twig/twig": "^3.0", + "symfony/console": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/ux-twig-component": "^2.23", + "symfony/yaml": "^6.4|^7.0" + }, + "require-dev": { + "symfony/finder": "6.4|^7.0", + "twig/extra-bundle": "^3.19|^4.0", + "twig/html-extra": "^3.19", + "zenstruck/console-test": "^1.7", + "symfony/http-client": "6.4|^7.0", + "symfony/stopwatch": "^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", + "tales-from-a-dev/twig-tailwind-extra": "^0.4.0" + }, + "bin": [ + "bin/ux-toolkit-kit-create", + "bin/ux-toolkit-kit-debug" + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Toolkit\\": "src" + }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Toolkit\\Tests\\": "tests/" + } + }, + "conflict": { + "symfony/ux-twig-component": "<2.21" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + } +} diff --git a/src/Toolkit/config/services.php b/src/Toolkit/config/services.php new file mode 100644 index 00000000000..3406e962806 --- /dev/null +++ b/src/Toolkit/config/services.php @@ -0,0 +1,87 @@ + + * + * 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\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; +use Symfony\UX\Toolkit\Registry\LocalRegistry; +use Symfony\UX\Toolkit\Registry\RegistryFactory; +use Symfony\UX\Toolkit\Registry\Type; + +/* + * @author Hugo Alliaume + */ +return static function (ContainerConfigurator $container): void { + $container->services() + // Commands + + ->set('.ux_toolkit.command.debug_kit', DebugKitCommand::class) + ->args([ + service('.ux_toolkit.kit.kit_factory'), + ]) + ->tag('console.command') + + ->set('.ux_toolkit.command.install', InstallComponentCommand::class) + ->args([ + service('.ux_toolkit.registry.registry_factory'), + service('filesystem'), + ]) + ->tag('console.command') + + // Registry + + ->set('.ux_toolkit.registry.registry_factory', RegistryFactory::class) + ->args([ + service_locator([ + Type::Local->value => service('.ux_toolkit.registry.local'), + Type::GitHub->value => service('.ux_toolkit.registry.github'), + ]), + ]) + + ->set('.ux_toolkit.registry.local', LocalRegistry::class) + ->args([ + service('.ux_toolkit.kit.kit_factory'), + service('filesystem'), + ]) + + ->set('.ux_toolkit.registry.github', GitHubRegistry::class) + ->args([ + service('.ux_toolkit.kit.kit_factory'), + service('filesystem'), + service('http_client')->nullOnInvalid(), + ]) + + // Kit + + ->set('.ux_toolkit.kit.kit_factory', KitFactory::class) + ->args([ + service('filesystem'), + service('.ux_toolkit.kit.kit_synchronizer'), + ]) + + ->set('.ux_toolkit.kit.kit_synchronizer', KitSynchronizer::class) + ->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/doc/index.rst b/src/Toolkit/doc/index.rst new file mode 100644 index 00000000000..556ef1e669b --- /dev/null +++ b/src/Toolkit/doc/index.rst @@ -0,0 +1,140 @@ +Symfony UX Toolkit +================== + +**EXPERIMENTAL** This component is currently experimental and is likely +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 +of `Twig components`_ (based on Tailwind CSS, but fully customizable depending +on your needs). + +Please note that the **UX Toolkit is not a library of UI components**, +but **a tool to help you build your own UI components**. +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 `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`` +and ``tales-from-a-dev/twig-tailwind-extra`` to use them. + +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 +------------ + +Install the UX Toolkit using Composer and Symfony Flex: + +.. code-block:: terminal + + # 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 Twig components: + $ composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra + +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 a `Button` component, you will find the following instruction: + +.. code-block:: terminal + + $ 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: + +.. code-block:: html+twig + + Click me + +Create your own kit +------------------- + +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 + + # Create your new project + $ mkdir my-ux-toolkit-kit + $ cd my-ux-toolkit-kit + + # Initialize your project + $ git init + $ composer init + + # Install the UX Toolkit + $ composer require --dev symfony/ux-toolkit + + # Create your kit + $ php vendor/bin/ux-toolkit-kit-create + + # ... edit the files, add your components, examples, etc. + + # Share your kit + $ git add . + $ git commit -m "Create my-kit UX Toolkit" + $ git branch -M main + $ git remote add origin git@github.com:my-username/my-ux-toolkit-kit.git + $ git push -u origin main + +Repository and kits structure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +After creating your kit, the repository should have the following structure: + +.. code-block:: text + + . + ├── docs + │ └── components + │ └── Button.twig + ├── manifest.json + └── templates + └── components + └── Button.html.twig + +A kit is composed of: + +- A ``manifest.json`` file, that describes the kit (name, license, homepage, authors, ...), +- A ``templates/components`` directory, that contains the Twig components, +- A ``docs/components`` directory, optional, that contains the documentation for each "root" Twig component. + +Using your kit +~~~~~~~~~~~~~~ + +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-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 +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +However, the UI components and other files provided by the Toolkit **are not** covered by the Backward Compatibility +promise. +We may break them in patch or minor release, but you won't get impacted unless you re-install the same UI component. + +.. _`the Symfony UX initiative`: https://ux.symfony.com/ +.. _`Twig components`: https://symfony.com/bundles/ux-twig-component/current/index.html +.. _`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/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/docs/components/Alert.md b/src/Toolkit/kits/shadcn/docs/components/Alert.md new file mode 100644 index 00000000000..5317941445c --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Alert.md @@ -0,0 +1,47 @@ +# Alert + +A notification component that displays important messages with an icon, title, and description. + +```twig {"preview":true} + + + Heads up! + + You can add components to your app using the cli. + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + Heads up! + + You can add components to your app using the cli. + + +``` + +### Destructive + +```twig {"preview":true} + + + Error + + 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 new file mode 100644 index 00000000000..4c8e2d32865 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md @@ -0,0 +1,47 @@ +# AspectRatio + +A container that maintains a specific width-to-height ratio for its content. + +```twig {"preview":true,"height":"400px"} + + Landscape photograph by Tobias Tullius + +``` + +## Installation + + + +## Usage + + + +## Examples + +### With a 1 / 1 aspect ratio + +```twig {"preview":true,"height":"400px"} + + Landscape photograph by Tobias Tullius + +``` + +### With a 16 / 9 aspect ratio + +```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 new file mode 100644 index 00000000000..022831b0ba9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Avatar.md @@ -0,0 +1,56 @@ +# Avatar + +A circular element that displays a user's profile image or initials as a fallback. + +```twig {"preview":true} + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Avatar with Image + +```twig {"preview":true} + + + +``` + +### Avatar with Text + +```twig {"preview":true} +
    + + FP + + + FP + +
    +``` + +### Avatar Group + +```twig {"preview":true} +
    + + + + + FP + + + FP + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Badge.md b/src/Toolkit/kits/shadcn/docs/components/Badge.md new file mode 100644 index 00000000000..68c13efb739 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Badge.md @@ -0,0 +1,56 @@ +# Badge + +A small element that displays status, counts, or labels with optional icons. + +```twig {"preview":true} +Badge +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +Badge +``` + +### Secondary + +```twig {"preview":true} + + Badge + +``` + +### Outline + +```twig {"preview":true} + + Badge + +``` + +### Destructive + +```twig {"preview":true} + + Badge + +``` + +### With Icon + +```twig {"preview":true} + + + Verified + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md new file mode 100644 index 00000000000..680e7b8558f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md @@ -0,0 +1,89 @@ +# Breadcrumb + +A navigation element that shows the current page's location in the site hierarchy with clickable links. + +```twig {"preview":true} + + + + Home + + + + Docs + + + + Components + + + + Breadcrumb + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + + Home + + + + Docs + + + + Components + + + + Breadcrumb + + + +``` + +### Custom Separator + +```twig {"preview":true} + + + + Home + + + + + + Docs + + + + + + Components + + + + + + Breadcrumb + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Button.md b/src/Toolkit/kits/shadcn/docs/components/Button.md new file mode 100644 index 00000000000..8dd9530b0ce --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Button.md @@ -0,0 +1,87 @@ +# Button + +A clickable element that triggers actions or events, supporting various styles and states. + +```twig {"preview":true} + + Click me + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + Click me + +``` + +### Primary + +```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 + +```twig {"preview":true} + + + +``` + +### With Icon + +```twig {"preview":true} + + Login with Email + +``` + +### Loading + +```twig {"preview":true} + + Please wait + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Card.md b/src/Toolkit/kits/shadcn/docs/components/Card.md new file mode 100644 index 00000000000..f482b75f2e4 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Card.md @@ -0,0 +1,84 @@ +# Card + +A container that groups related content and actions into a box with optional header, content, and footer sections. + +```twig {"preview":true,"height":"300px"} + + + Card Title + Card Description + + +

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

    +
    + + Cancel + Action + +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true,"height":"300px"} + + + Card Title + Card Description + + +

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

    +
    + + Cancel + Action + +
    +``` + +### With Notifications + +```twig {"preview":true,"height":"400px"} +{% set notifications = [ + { title: "Your call has been confirmed.", description: "1 hour ago"}, + { title: "You have a new message!", description: "1 hour ago"}, + { title: "Your subscription is expiring soon!", description: "2 hours ago" }, +] %} + + + Notifications + You have 3 unread messages. + + + {%- for notification in notifications -%} +
    + +
    +

    + {{ notification.title }} +

    +

    + {{ notification.description }} +

    +
    +
    + {%- endfor -%} +
    + + + + Mark all as read + + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Checkbox.md b/src/Toolkit/kits/shadcn/docs/components/Checkbox.md new file mode 100644 index 00000000000..40da5fee4d0 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Checkbox.md @@ -0,0 +1,51 @@ +# Checkbox + +A form control that allows the user to toggle between checked and unchecked states. + +```twig {"preview":true} +
    + + +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    + + +
    +``` + +### With Label Component + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` + +### Disabled + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` 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..de0b816a9d6 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Input.md @@ -0,0 +1,56 @@ +# Input + +A form control that allows users to enter text, numbers, or select files. + +```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/docs/components/Label.md b/src/Toolkit/kits/shadcn/docs/components/Label.md new file mode 100644 index 00000000000..ea41f854987 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Label.md @@ -0,0 +1,47 @@ +# Label + +A text element that identifies form controls and other content. + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` + +### With Input + +```twig {"preview":true} +
    + Email + +
    +``` + +### Required Field + +```twig {"preview":true} +
    + Email + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Pagination.md b/src/Toolkit/kits/shadcn/docs/components/Pagination.md new file mode 100644 index 00000000000..0efbea5159f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Pagination.md @@ -0,0 +1,101 @@ +# Pagination + +A navigation component that displays page numbers and controls for moving between pages. + +```twig {"preview":true} + + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + +``` + +### Symmetric + +```twig {"preview":true} + + + + + + + 1 + + + + + + 4 + + + 5 + + + 6 + + + + + + 9 + + + + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Progress.md b/src/Toolkit/kits/shadcn/docs/components/Progress.md new file mode 100644 index 00000000000..c2c89234a73 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Progress.md @@ -0,0 +1,47 @@ +# Progress + +A visual indicator that shows the completion status of a task or operation. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
    +
    + Loading + 33% +
    + +
    +``` + +### Different Values + +```twig {"preview":true} +
    + + + + + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Select.md b/src/Toolkit/kits/shadcn/docs/components/Select.md new file mode 100644 index 00000000000..91941c0fafe --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Select.md @@ -0,0 +1,54 @@ +# Select + +A dropdown control that allows users to choose from a list of options. + +```twig {"preview":true} + + + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + + + +``` + +### With Label + +```twig {"preview":true} +
    + Framework + + + + + +
    +``` + +### Disabled + +```twig {"preview":true} + + + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Separator.md b/src/Toolkit/kits/shadcn/docs/components/Separator.md new file mode 100644 index 00000000000..038197b45df --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Separator.md @@ -0,0 +1,65 @@ +# Separator + +A visual divider that creates space between content elements, available in horizontal and vertical orientations. + +```twig {"preview":true} +
    +
    +

    Symfony UX

    +

    + Symfony UX initiative: a JavaScript ecosystem for Symfony +

    +
    + +
    + Website + + Packages + + Source +
    +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    +
    +

    Symfony UX

    +

    + Symfony UX initiative: a JavaScript ecosystem for Symfony +

    +
    + +
    +
    Blog
    + +
    Docs
    + +
    Source
    +
    +
    +``` + +### Vertical + +```twig {"preview":true} +
    +
    Blog
    + +
    Docs
    + +
    Source
    +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Skeleton.md b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md new file mode 100644 index 00000000000..4c16a2c64bc --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md @@ -0,0 +1,47 @@ +# Skeleton + +A placeholder element that displays a loading state with an animated background. + +```twig {"preview":true} +
    + +
    + + +
    +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### User + +```twig {"preview":true} +
    + +
    + + +
    +
    +``` + +### Card + +```twig {"preview":true,"height":"250px"} +
    + +
    + + +
    +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Switch.md b/src/Toolkit/kits/shadcn/docs/components/Switch.md new file mode 100644 index 00000000000..17fa899f49f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Switch.md @@ -0,0 +1,53 @@ +# Switch + +A toggle control that switches between on and off states. + +```twig {"preview":true} +
    + + Airplane Mode +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    + + Airplane Mode +
    +``` + +### Form + +```twig {"preview":true,"height":"300px"} +
    +

    Email Notifications

    +
    +
    +
    + Marketing emails +

    Receive emails about new products, features, and more.

    +
    + +
    +
    +
    + Security emails +

    Receive emails about your account security.

    +
    + +
    +
    +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Table.md b/src/Toolkit/kits/shadcn/docs/components/Table.md new file mode 100644 index 00000000000..302d6bc91e2 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Table.md @@ -0,0 +1,83 @@ +# Table + +A structured grid element that organizes data into rows and columns, supporting headers, captions, and footers. + +```twig {"preview":true,"height":"400px"} +{%- set invoices = [ + { invoice: "INV001", paymentStatus: "Paid", totalAmount: "$250.00", paymentMethod: "Credit Card" }, + { invoice: "INV002", paymentStatus: "Pending", totalAmount: "$150.00", paymentMethod: "PayPal" }, + { invoice: "INV003", paymentStatus: "Unpaid", totalAmount: "$350.00", paymentMethod: "Bank Transfer" }, +] -%} + + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + {% for invoice in invoices %} + + {{ invoice.invoice }} + {{ invoice.paymentStatus }} + {{ invoice.paymentMethod }} + {{ invoice.totalAmount }} + + {% endfor %} + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Basic Table + +```twig {"preview":true,"height":"550px"} +{%- set invoices = [ + { invoice: "INV001", paymentStatus: "Paid", totalAmount: "$250.00", paymentMethod: "Credit Card" }, + { invoice: "INV002", paymentStatus: "Pending", totalAmount: "$150.00", paymentMethod: "PayPal" }, + { invoice: "INV003", paymentStatus: "Unpaid", totalAmount: "$350.00", paymentMethod: "Bank Transfer" }, + { invoice: "INV004", paymentStatus: "Paid", totalAmount: "$450.00", paymentMethod: "Credit Card" }, + { invoice: "INV005", paymentStatus: "Paid", totalAmount: "$550.00", paymentMethod: "PayPal" }, + { invoice: "INV006", paymentStatus: "Pending", totalAmount: "$200.00", paymentMethod: "Bank Transfer" }, + { invoice: "INV007", paymentStatus: "Unpaid", totalAmount: "$300.00", paymentMethod: "Credit Card" }, +] -%} + + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + {% for invoice in invoices %} + + {{ invoice.invoice }} + {{ invoice.paymentStatus }} + {{ invoice.paymentMethod }} + {{ invoice.totalAmount }} + + {% endfor %} + + + + Total + $1,500.00 + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Textarea.md b/src/Toolkit/kits/shadcn/docs/components/Textarea.md new file mode 100644 index 00000000000..b9babc362c9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Textarea.md @@ -0,0 +1,38 @@ +# Textarea + +A form control for entering multiple lines of text. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
    + + +
    +``` + +### Disabled + +```twig {"preview":true} + +``` diff --git a/src/Toolkit/kits/shadcn/manifest.json b/src/Toolkit/kits/shadcn/manifest.json new file mode 100644 index 00000000000..0c8a4c4d353 --- /dev/null +++ b/src/Toolkit/kits/shadcn/manifest.json @@ -0,0 +1,7 @@ +{ + "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", + "ux-icon": "simple-icons:shadcnui" +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig new file mode 100644 index 00000000000..d5ae97b9894 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig @@ -0,0 +1,18 @@ +{%- props variant = 'default' -%} +{%- set style = html_cva( + base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, +) -%} + + diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig new file mode 100644 index 00000000000..712d8722850 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..5a47394168a --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..f10b04ff765 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..40fd86e29fa --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..0b1dcac2e93 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..0422495a095 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..aa53c0920aa --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig @@ -0,0 +1,18 @@ +{%- 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: 'text-foreground', + }, + }, +) -%} +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.html.twig new file mode 100644 index 00000000000..8681b5131ee --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.html.twig @@ -0,0 +1,3 @@ + + {%- 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 new file mode 100644 index 00000000000..32aa63b39eb --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig @@ -0,0 +1,13 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.html.twig new file mode 100644 index 00000000000..e3e28dedda9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.html.twig @@ -0,0 +1,6 @@ +
  • + {%- block content %}{% endblock -%} +
  • diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.html.twig new file mode 100644 index 00000000000..42cfe143bb2 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.html.twig new file mode 100644 index 00000000000..f21230fc2c9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.html.twig @@ -0,0 +1,6 @@ +
      + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.html.twig new file mode 100644 index 00000000000..a1c8da7102e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.html.twig @@ -0,0 +1,9 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.html.twig new file mode 100644 index 00000000000..2e11b3871f5 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.html.twig @@ -0,0 +1,10 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Button.html.twig b/src/Toolkit/kits/shadcn/templates/components/Button.html.twig new file mode 100644 index 00000000000..45592a2f6cf --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Button.html.twig @@ -0,0 +1,27 @@ +{%- props variant = 'default', outline = false, size = 'default', as = 'button' -%} +{%- set style = html_cva( + 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', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + 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', + }, + }, +) -%} + +<{{ as }} + class="{{ style.apply({variant, outline, size}, attributes.render('class'))|tailwind_merge }}" + {{ attributes }} +> + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Card.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card.html.twig new file mode 100644 index 00000000000..96ef038885d --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Content.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Content.html.twig new file mode 100644 index 00000000000..8e73fefbc9d --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Content.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Description.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Description.html.twig new file mode 100644 index 00000000000..4b8c5e669cb --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Description.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Footer.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Footer.html.twig new file mode 100644 index 00000000000..a8e9cdc0999 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Footer.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Header.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Header.html.twig new file mode 100644 index 00000000000..171ad5752b9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Header.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Title.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Title.html.twig new file mode 100644 index 00000000000..e7460a85df8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Title.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig b/src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig new file mode 100644 index 00000000000..69730f09be1 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig @@ -0,0 +1,5 @@ + 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..cc3c21c4305 --- /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..be0d3058264 --- /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/Pagination.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig new file mode 100644 index 00000000000..3c746f7669d --- /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..8f9bcc5ba79 --- /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..b3c11aa0d8a --- /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..666c858cb7e --- /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..22e0e3612c8 --- /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..d228265d0fe --- /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 new file mode 100644 index 00000000000..b9f3399d446 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig @@ -0,0 +1,8 @@ +
    + + {%- 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 new file mode 100644 index 00000000000..a9c34280da5 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..e737398c135 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..51fe7c95af0 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..5e4a5628e6e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..bfa630a91f3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..94aee678f4b --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..e58858a2053 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig @@ -0,0 +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 new file mode 100644 index 00000000000..317a57e1774 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig @@ -0,0 +1,4 @@ + diff --git a/src/Toolkit/phpunit.xml.dist b/src/Toolkit/phpunit.xml.dist new file mode 100644 index 00000000000..0a4c3bed992 --- /dev/null +++ b/src/Toolkit/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + tests + + + + + + + + src + + + diff --git a/src/Toolkit/src/Assert.php b/src/Toolkit/src/Assert.php new file mode 100644 index 00000000000..70dcb33cc4d --- /dev/null +++ b/src/Toolkit/src/Assert.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit; + +final class Assert +{ + /** + * Assert that the kit name is valid (ex: "Shadcn", "Tailwind", "Bootstrap", etc.). + * + * @param non-empty-string $name + * + * @throws \InvalidArgumentException if the kit name is invalid + */ + 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)) { + throw new \InvalidArgumentException(\sprintf('Invalid kit name "%s".', $name)); + } + } + + /** + * Assert that the component name is valid (ex: "Button", "Input", "Card", "Card:Header", etc.). + * + * @param non-empty-string $name + * + * @throws \InvalidArgumentException if the component name is invalid + */ + public static function componentName(string $name): void + { + if (1 !== preg_match('/^[A-Z][a-zA-Z0-9]*(?::[A-Z][a-zA-Z0-9]*)*$/', $name)) { + throw new \InvalidArgumentException(\sprintf('Invalid component name "%s".', $name)); + } + } + + /** + * Assert that the PHP package name is valid (ex: "twig/html-extra", "symfony/framework-bundle", etc.). + * + * @param non-empty-string $name + * + * @throws \InvalidArgumentException if the PHP package name is invalid + */ + 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 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/Asset/Component.php b/src/Toolkit/src/Asset/Component.php new file mode 100644 index 00000000000..09dacbdf7d9 --- /dev/null +++ b/src/Toolkit/src/Asset/Component.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\Asset; + +use Symfony\UX\Toolkit\Assert; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\DependencyInterface; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\File\Doc; +use Symfony\UX\Toolkit\File\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Component +{ + /** + * @param non-empty-string $name + * @param list $files + */ + public function __construct( + public readonly string $name, + public readonly array $files, + public ?Doc $doc = null, + private array $dependencies = [], + ) { + Assert::componentName($name); + + if ([] === $files) { + throw new \InvalidArgumentException(\sprintf('The component "%s" must have at least one file.', $name)); + } + } + + public function addDependency(DependencyInterface $dependency): void + { + foreach ($this->dependencies as $i => $existingDependency) { + if ($existingDependency instanceof PhpPackageDependency && $existingDependency->name === $dependency->name) { + if ($existingDependency->isHigherThan($dependency)) { + return; + } + + $this->dependencies[$i] = $dependency; + + return; + } + + if ($existingDependency instanceof ComponentDependency && $existingDependency->name === $dependency->name) { + return; + } + + if ($existingDependency instanceof StimulusControllerDependency && $existingDependency->name === $dependency->name) { + return; + } + } + + $this->dependencies[] = $dependency; + } + + /** + * @return list + */ + public function getDependencies(): array + { + return $this->dependencies; + } +} 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/CreateKitCommand.php b/src/Toolkit/src/Command/CreateKitCommand.php new file mode 100644 index 00000000000..42f86014240 --- /dev/null +++ b/src/Toolkit/src/Command/CreateKitCommand.php @@ -0,0 +1,146 @@ + + * + * 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\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\Toolkit\Assert; + +/** + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:create-kit', + description: 'Create a new kit', + hidden: true, +)] +class CreateKitCommand extends Command +{ + public function __construct( + private readonly Filesystem $filesystem, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + // Get the kit name + $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.'); + } + Assert::kitName($value); + + return $value; + }); + $kitName = $io->askQuestion($question); + + // Get the kit homepage + $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 URL must be valid.'); + } + + return $value; + }); + $kitHomepage = $io->askQuestion($question); + + // Get the kit license + $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.'); + } + + return $value; + }); + $kitLicense = $io->askQuestion($question); + + // Create the kit + $this->filesystem->dumpFile('manifest.json', json_encode([ + 'name' => $kitName, + 'homepage' => $kitHomepage, + 'license' => $kitLicense, + ], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); + $this->filesystem->dumpFile('templates/components/Button.html.twig', << + {%- block content %}{% endblock -%} + +TWIG + ); + $this->filesystem->dumpFile('docs/components/Button.md', << + Click me + +``` + +## Examples + +### Button with Variants + +```twig +Default +Secondary +``` + +TWIG + ); + + $io->success('Your kit has been scaffolded, enjoy!'); + + return self::SUCCESS; + } +} diff --git a/src/Toolkit/src/Command/DebugKitCommand.php b/src/Toolkit/src/Command/DebugKitCommand.php new file mode 100644 index 00000000000..6a79cb84d71 --- /dev/null +++ b/src/Toolkit/src/Command/DebugKitCommand.php @@ -0,0 +1,98 @@ + + * + * 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\Helper\Table; +use Symfony\Component\Console\Helper\TableSeparator; +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\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Kit\KitFactory; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:debug-kit', + description: 'Debug a local Kit.', + hidden: true, +)] +class DebugKitCommand extends Command +{ + public function __construct( + private readonly KitFactory $kitFactory, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('kit-path', InputArgument::OPTIONAL, 'The path to the kit to debug', '.') + ->setHelp(<<<'EOF' +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 + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitPath = $input->getArgument('kit-path'); + $kitPath = Path::makeAbsolute($kitPath, getcwd()); + $kit = $this->kitFactory->createKitFromAbsolutePath($kitPath); + + $io->title(\sprintf('Kit "%s"', $kit->name)); + + $io->definitionList( + ['Name' => $kit->name], + ['Homepage' => $kit->homepage], + ['License' => $kit->license], + new TableSeparator(), + ['Path' => $kit->path], + ); + + $io->section('Components'); + foreach ($kit->getComponents() as $component) { + (new Table($io)) + ->setHeaderTitle(\sprintf('Component: "%s"', $component->name)) + ->setHorizontal() + ->setHeaders([ + 'File(s)', + 'Dependencies', + ]) + ->addRow([ + implode("\n", $component->files), + implode("\n", $component->getDependencies()), + ]) + ->setColumnWidth(1, 80) + ->setColumnMaxWidth(1, 80) + ->render(); + $io->newLine(); + } + + return Command::SUCCESS; + } +} diff --git a/src/Toolkit/src/Command/InstallComponentCommand.php b/src/Toolkit/src/Command/InstallComponentCommand.php new file mode 100644 index 00000000000..83dd56eebf9 --- /dev/null +++ b/src/Toolkit/src/Command/InstallComponentCommand.php @@ -0,0 +1,222 @@ + + * + * 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\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\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\LocalRegistry; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:install-component', + description: 'Install a new UX Component (e.g. Alert) in your project', +)] +class InstallComponentCommand extends Command +{ + private SymfonyStyle $io; + private bool $isInteractive; + + public function __construct( + private readonly RegistryFactory $registryFactory, + private readonly Filesystem $filesystem, + ) { + parent::__construct(); + } + + 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', + InputOption::VALUE_OPTIONAL, + 'The destination directory', + Path::join('templates', 'components') + ) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the component installation, even if the component already exists') + ->setHelp( + <<%command.name% command will install a new UX Component in your project. + +To install a component from your current kit, use: + +php %command.full_name% Button + +To install a component from an official UX Toolkit kit, use the --kit option: + +php %command.full_name% Button --kit=shadcn + +To install a component from an external GitHub kit, use the --kit option: + +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 + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitName = $input->getOption('kit'); + $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) { + // 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); + $alternativeComponentsCount = \count($alternativeComponents); + + if (1 === $alternativeComponentsCount && $input->isInteractive()) { + $io->warning($message); + if ($io->confirm(\sprintf('Do you want to install the component "%s" instead?', $alternativeComponents[0]->name))) { + $component = $alternativeComponents[0]; + } else { + return Command::FAILURE; + } + } elseif ($alternativeComponentsCount > 0) { + $io->warning(\sprintf('%s'."\n".'Possible alternatives: "%s"', $message, implode('", "', array_map(fn (Component $c) => $c->name, $alternativeComponents)))); + + return Command::FAILURE; + } else { + $io->error($message); + + return Command::FAILURE; + } + } + + $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')); + + if ([] === $installationReport->newFiles) { + $this->io->warning('The component has not been installed.'); + + return Command::SUCCESS; + } + + $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 ([] !== $installationReport->suggestedPhpPackages) { + $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $installationReport->suggestedPhpPackages))); + $this->io->newLine(); + } + + return Command::SUCCESS; + } + + /** + * @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 + { + $alternative = []; + + foreach ($kit->getComponents() as $component) { + $lev = levenshtein($componentName, $component->name, 2, 5, 10); + if ($lev <= 8 || str_contains($component->name, $componentName)) { + $alternative[] = $component; + } + } + + return $alternative; + } +} diff --git a/src/Toolkit/src/Dependency/ComponentDependency.php b/src/Toolkit/src/Dependency/ComponentDependency.php new file mode 100644 index 00000000000..7811c3ae34c --- /dev/null +++ b/src/Toolkit/src/Dependency/ComponentDependency.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 component. + * + * @internal + * + * @author Hugo Alliaume + */ +final class ComponentDependency implements DependencyInterface +{ + /** + * @param non-empty-string $name The name of the component, e.g. "Table" or "Table:Body" + */ + public function __construct( + public string $name, + ) { + Assert::componentName($this->name); + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Toolkit/src/Dependency/DependencyInterface.php b/src/Toolkit/src/Dependency/DependencyInterface.php new file mode 100644 index 00000000000..60957d99ce7 --- /dev/null +++ b/src/Toolkit/src/Dependency/DependencyInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +/** + * Represents a dependency. + * + * @internal + * + * @author Hugo Alliaume + */ +interface DependencyInterface extends \Stringable +{ +} diff --git a/src/Toolkit/src/Dependency/PhpPackageDependency.php b/src/Toolkit/src/Dependency/PhpPackageDependency.php new file mode 100644 index 00000000000..fed6e153540 --- /dev/null +++ b/src/Toolkit/src/Dependency/PhpPackageDependency.php @@ -0,0 +1,48 @@ + + * + * 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 PHP package. + * + * @internal + * + * @author Hugo Alliaume + */ +final class PhpPackageDependency implements DependencyInterface +{ + /** + * @param non-empty-string $name + */ + public function __construct( + public readonly string $name, + public readonly ?Version $constraintVersion = null, + ) { + Assert::phpPackageName($name); + } + + public function isHigherThan(self $dependency): bool + { + if (null === $this->constraintVersion || null === $dependency->constraintVersion) { + return false; + } + + return $this->constraintVersion->isHigherThan($dependency->constraintVersion); + } + + public function __toString(): string + { + return $this->name.($this->constraintVersion ? ':^'.$this->constraintVersion : ''); + } +} diff --git a/src/Toolkit/src/Dependency/StimulusControllerDependency.php b/src/Toolkit/src/Dependency/StimulusControllerDependency.php new file mode 100644 index 00000000000..f1319b18033 --- /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 DependencyInterface +{ + /** + * @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 new file mode 100644 index 00000000000..541d936b35e --- /dev/null +++ b/src/Toolkit/src/Dependency/Version.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +/** + * Represents a version number, following the SemVer specification. + * + * @internal + * + * @author Hugo Alliaume + */ +final class Version implements \Stringable +{ + /** + * @param non-empty-string + */ + public function __construct( + public readonly string $value, + ) { + } + + public function isHigherThan(self $version): bool + { + return version_compare($this->value, $version->value, '>'); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Toolkit/src/File/Doc.php b/src/Toolkit/src/File/Doc.php new file mode 100644 index 00000000000..1c66f4b619e --- /dev/null +++ b/src/Toolkit/src/File/Doc.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Doc +{ + /** + * @param non-empty-string $markdownContent + */ + public function __construct( + public readonly string $markdownContent, + ) { + } +} diff --git a/src/Toolkit/src/File/File.php b/src/Toolkit/src/File/File.php new file mode 100644 index 00000000000..df43a7c7dae --- /dev/null +++ b/src/Toolkit/src/File/File.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 Symfony\UX\Toolkit\File; + +use Symfony\Component\Filesystem\Path; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class File implements \Stringable +{ + /** + * @param non-empty-string $relativePathNameToKit relative path from the kit root directory, example "templates/components/Table/Body.html.twig" + * @param non-empty-string $relativePathName relative path name, without any prefix, example "Table/Body.html.twig" + * + * @throws \InvalidArgumentException + */ + public function __construct( + 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)); + } + + if (!Path::isRelative($relativePathName)) { + throw new \InvalidArgumentException(\sprintf('The path name "%s" must be relative.', $relativePathName)); + } + + if (!str_ends_with($relativePathNameToKit, $relativePathName)) { + throw new \InvalidArgumentException(\sprintf('The relative path name "%s" must be a subpath of the relative path to the kit "%s".', $relativePathName, $relativePathNameToKit)); + } + } + + public function __toString(): string + { + return \sprintf('%s (%s)', $this->relativePathNameToKit, $this->type->getLabel()); + } +} diff --git a/src/Toolkit/src/File/FileType.php b/src/Toolkit/src/File/FileType.php new file mode 100644 index 00000000000..26506f3a868 --- /dev/null +++ b/src/Toolkit/src/File/FileType.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +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 new file mode 100644 index 00000000000..dcf69290581 --- /dev/null +++ b/src/Toolkit/src/Kit/Kit.php @@ -0,0 +1,112 @@ + + * + * 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\Assert; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Asset\StimulusController; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Kit +{ + /** + * @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 ?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); + + if (!Path::isAbsolute($this->path)) { + throw new \InvalidArgumentException(\sprintf('Kit path "%s" is not absolute.', $this->path)); + } + + if (null !== $this->homepage && !filter_var($this->homepage, \FILTER_VALIDATE_URL)) { + throw new \InvalidArgumentException(\sprintf('Invalid homepage URL "%s".', $this->homepage)); + } + } + + /** + * @throws \InvalidArgumentException if the component is already registered in the kit + */ + public function addComponent(Component $component): void + { + foreach ($this->components as $existingComponent) { + if ($existingComponent->name === $component->name) { + throw new \InvalidArgumentException(\sprintf('Component "%s" is already registered in the kit.', $component->name)); + } + } + + $this->components[] = $component; + } + + public function getComponents(): array + { + return $this->components; + } + + public function getComponent(string $name): ?Component + { + foreach ($this->components as $component) { + if ($component->name === $name) { + return $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/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/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php new file mode 100644 index 00000000000..039258c2077 --- /dev/null +++ b/src/Toolkit/src/Kit/KitFactory.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\Kit; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class KitFactory +{ + public function __construct( + private readonly Filesystem $filesystem, + private readonly KitSynchronizer $kitSynchronizer, + ) { + } + + /** + * @throws \InvalidArgumentException if the manifest file is missing a required key + * @throws \JsonException if the manifest file is not valid JSON + */ + public function createKitFromAbsolutePath(string $absolutePath): Kit + { + if (!Path::isAbsolute($absolutePath)) { + throw new \InvalidArgumentException(\sprintf('Path "%s" is not absolute.', $absolutePath)); + } + + if (!$this->filesystem->exists($absolutePath)) { + throw new \InvalidArgumentException(\sprintf('Path "%s" does not exist.', $absolutePath)); + } + + if (!$this->filesystem->exists($manifestPath = Path::join($absolutePath, 'manifest.json'))) { + throw new \InvalidArgumentException(\sprintf('File "%s" not found.', $manifestPath)); + } + + $manifest = json_decode(file_get_contents($manifestPath), true, flags: \JSON_THROW_ON_ERROR); + + $kit = new 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.'), + license: $manifest['license'] ?? throw new \InvalidArgumentException('Manifest file is missing "license" key.'), + description: $manifest['description'] ?? null, + uxIcon: $manifest['ux-icon'] ?? null, + ); + + $this->kitSynchronizer->synchronize($kit); + + return $kit; + } +} diff --git a/src/Toolkit/src/Kit/KitSynchronizer.php b/src/Toolkit/src/Kit/KitSynchronizer.php new file mode 100644 index 00000000000..e5907ae2618 --- /dev/null +++ b/src/Toolkit/src/Kit/KitSynchronizer.php @@ -0,0 +1,202 @@ + + * + * 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) { + 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')) { + $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 = 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(file_get_contents($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/GitHubRegistry.php b/src/Toolkit/src/Registry/GitHubRegistry.php new file mode 100644 index 00000000000..43f3104b7b0 --- /dev/null +++ b/src/Toolkit/src/Registry/GitHubRegistry.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitFactory; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +final class GitHubRegistry implements RegistryInterface +{ + public function __construct( + private readonly KitFactory $kitFactory, + private readonly Filesystem $filesystem, + private ?HttpClientInterface $httpClient = null, + ) { + if (null === $httpClient) { + if (!class_exists(HttpClient::class)) { + throw new \LogicException('You must install "symfony/http-client" to use the UX Toolkit with remote components. Try running "composer require symfony/http-client".'); + } + + $this->httpClient = HttpClient::create(); + } + + if (!class_exists(\ZipArchive::class)) { + throw new \LogicException('You must have the Zip extension installed to use UX Toolkit with remote registry.'); + } + } + + /** + * @see https://regex101.com/r/0BoRNX/1 + */ + public const RE_GITHUB_KIT = '/^(?:https:\/\/)?(github\.com)\/(?[\w-]+)\/(?[\w-]+)(?::(?[\w._-]+))?$/'; + + public static function supports(string $kitName): bool + { + return 1 === preg_match(self::RE_GITHUB_KIT, $kitName); + } + + public function getKit(string $kitName): Kit + { + $repositoryDir = $this->downloadRepository(GitHubRegistryIdentity::fromUrl($kitName)); + + return $this->kitFactory->createKitFromAbsolutePath($repositoryDir); + } + + /** + * @throws \RuntimeException + */ + private function downloadRepository(GitHubRegistryIdentity $identity): string + { + $zipUrl = \sprintf( + 'https://github.com/%s/%s/archive/%s.zip', + $identity->authorName, + $identity->repositoryName, + $identity->version, + ); + + $tmpDir = $this->createTmpDir(); + $archiveExtractedName = \sprintf('%s-%s', $identity->repositoryName, $identity->version); + $archiveName = \sprintf('%s.zip', $archiveExtractedName); + $archivePath = Path::join($tmpDir, $archiveName); + $archiveExtractedDir = Path::join($tmpDir, $archiveExtractedName); + + // Download and stream the archive + $response = $this->httpClient->request('GET', $zipUrl); + if (200 !== $response->getStatusCode()) { + throw new \RuntimeException(\sprintf('Unable to download the archive from "%s", ensure the repository exists and the version is valid.', $zipUrl)); + } + + $archiveResource = fopen($archivePath, 'w'); + foreach ($this->httpClient->stream($response) as $chunk) { + fwrite($archiveResource, $chunk->getContent()); + } + fclose($archiveResource); + + // Extract the archive + $zip = new \ZipArchive(); + $zip->open($archivePath); + $zip->extractTo($tmpDir); + $zip->close(); + + if (!$this->filesystem->exists($archiveExtractedDir)) { + throw new \RuntimeException(\sprintf('Unable to extract the archive from "%s", ensure the repository exists and the version is valid.', $zipUrl)); + } + + return $archiveExtractedDir; + } + + private function createTmpDir(): string + { + $dir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_github_'); + $this->filesystem->remove($dir); + $this->filesystem->mkdir($dir); + + return $dir; + } +} diff --git a/src/Toolkit/src/Registry/GitHubRegistryIdentity.php b/src/Toolkit/src/Registry/GitHubRegistryIdentity.php new file mode 100644 index 00000000000..58beeddd40e --- /dev/null +++ b/src/Toolkit/src/Registry/GitHubRegistryIdentity.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\Registry; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +final class GitHubRegistryIdentity +{ + /** + * @param non-empty-string $authorName + * @param non-empty-string $repositoryName + * @param non-empty-string $version + */ + private function __construct( + public readonly string $authorName, + public readonly string $repositoryName, + public readonly string $version, + ) { + } + + public static function fromUrl(string $url): self + { + $matches = []; + if (1 !== preg_match(GitHubRegistry::RE_GITHUB_KIT, $url, $matches)) { + throw new \InvalidArgumentException('The kit name is invalid, it must be a valid GitHub kit name.'); + } + + return new self( + $matches['authorName'] ?: throw new \InvalidArgumentException('Unable to extract the author name from the URL.'), + $matches['repositoryName'] ?: throw new \InvalidArgumentException('Unable to extract the repository name from the URL.'), + $matches['version'] ?? 'main', + ); + } +} diff --git a/src/Toolkit/src/Registry/LocalRegistry.php b/src/Toolkit/src/Registry/LocalRegistry.php new file mode 100644 index 00000000000..dc8577ce955 --- /dev/null +++ b/src/Toolkit/src/Registry/LocalRegistry.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +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; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +final class LocalRegistry implements RegistryInterface +{ + 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); + } + + public function __construct( + private readonly KitFactory $kitFactory, + private readonly Filesystem $filesystem, + ) { + } + + public function getKit(string $kitName): Kit + { + $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/Registry/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php new file mode 100644 index 00000000000..e9a0e50ecda --- /dev/null +++ b/src/Toolkit/src/Registry/RegistryFactory.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 Symfony\UX\Toolkit\Registry; + +use Psr\Container\ContainerInterface; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +final class RegistryFactory +{ + public function __construct( + private readonly ContainerInterface $registries, + ) { + } + + /** + * @throws \InvalidArgumentException + */ + public function getForKit(string $kit): RegistryInterface + { + $type = match (true) { + GitHubRegistry::supports($kit) => Type::GitHub, + LocalRegistry::supports($kit) => Type::Local, + default => throw new \InvalidArgumentException(\sprintf('The kit "%s" is not valid.', $kit)), + }; + + if (!$this->registries->has($type->value)) { + throw new \LogicException(\sprintf('The registry for the kit "%s" is not registered.', $kit)); + } + + return $this->registries->get($type->value); + } +} diff --git a/src/Toolkit/src/Registry/RegistryInterface.php b/src/Toolkit/src/Registry/RegistryInterface.php new file mode 100644 index 00000000000..246ddadac99 --- /dev/null +++ b/src/Toolkit/src/Registry/RegistryInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Symfony\UX\Toolkit\Kit\Kit; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +interface RegistryInterface +{ + public static function supports(string $kitName): bool; + + /** + * @throws \RuntimeException if the kit does not exist + */ + public function getKit(string $kitName): Kit; +} diff --git a/src/Toolkit/src/Registry/Type.php b/src/Toolkit/src/Registry/Type.php new file mode 100644 index 00000000000..e0b4ef79e8b --- /dev/null +++ b/src/Toolkit/src/Registry/Type.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +enum Type: string +{ + case Local = 'local'; + case GitHub = 'github'; +} diff --git a/src/Toolkit/src/UXToolkitBundle.php b/src/Toolkit/src/UXToolkitBundle.php new file mode 100644 index 00000000000..0e38f2a251d --- /dev/null +++ b/src/Toolkit/src/UXToolkitBundle.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +class UXToolkitBundle extends AbstractBundle +{ + protected string $extensionAlias = 'ux_toolkit'; + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + } +} diff --git a/src/Toolkit/tests/AssertTest.php b/src/Toolkit/tests/AssertTest.php new file mode 100644 index 00000000000..5294e7dd83d --- /dev/null +++ b/src/Toolkit/tests/AssertTest.php @@ -0,0 +1,221 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Assert; + +class AssertTest extends TestCase +{ + /** + * @dataProvider provideValidKitNames + */ + public function testValidKitName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::kitName($name); + } + + public static function provideValidKitNames(): \Generator + { + yield ['my-kit']; + yield ['my-kit-with-dashes']; + yield ['1-my-kit']; + yield ['my-kit-1']; + yield ['my-kit-1-with-dashes']; + yield ['Shadcn UI']; + yield ['Shadcn UI-1']; + // Single character + yield ['a']; + yield ['1']; + // Maximum length (63 chars) + yield ['a'.str_repeat('-', 61).'a']; + // Various valid patterns + yield ['abc123']; + yield ['123abc']; + yield ['a1b2c3']; + yield ['a-b-c']; + yield ['a1-b2-c3']; + yield ['A1-B2-C3']; + yield ['my_kit']; + } + + /** + * @dataProvider provideInvalidKitNames + */ + public function testInvalidKitName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid kit name "%s".', $name)); + + Assert::kitName($name); + } + + public static function provideInvalidKitNames(): \Generator + { + yield ['my-kit-']; + yield ['my-kit/qsd']; + // Empty string + yield ['']; + // Starting with hyphen + yield ['-my-kit']; + // Ending with hyphen + yield ['my-kit-']; + // Invalid characters + yield ['my.kit']; + yield ['my@kit']; + // Too long (64 chars) + yield ['a'.str_repeat('-', 62).'a']; + // Starting with invalid character + yield ['-abc']; + yield ['@abc']; + yield ['.abc']; + } + + /** + * @dataProvider provideValidComponentNames + */ + public function testValidComponentName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::componentName($name); + } + + public static function provideValidComponentNames(): iterable + { + yield ['Table']; + yield ['TableBody']; + yield ['Table:Body']; + yield ['Table:Body:Header']; + yield ['MyComponent']; + yield ['MyComponent:SubComponent']; + yield ['A']; + yield ['A:B']; + yield ['Component123']; + yield ['Component123:Sub456']; + } + + /** + * @dataProvider provideInvalidComponentNames + */ + public function testInvalidComponentName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid component name "%s".', $name)); + + Assert::componentName($name); + } + + public static function provideInvalidComponentNames(): iterable + { + // Empty string + yield ['']; + // Invalid characters + yield ['table-body']; + yield ['table_body']; + yield ['table.body']; + yield ['table@body']; + yield ['table/body']; + // Starting with invalid characters + yield [':Table']; + yield ['123Table']; + yield ['@Table']; + // Invalid colon usage + yield ['Table:']; + yield ['Table::Body']; + yield [':Table:Body']; + // Lowercase start + yield ['table']; + yield ['table:Body']; + // Numbers only + yield ['123']; + yield ['123:456']; + } + + /** + * @dataProvider provideValidPhpPackageNames + */ + public function testValidPhpPackageName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::phpPackageName($name); + } + + public static function provideValidPhpPackageNames(): iterable + { + yield ['twig/html-extra']; + yield ['tales-from-a-dev/twig-tailwind-extra']; + } + + /** + * @dataProvider provideInvalidPhpPackageNames + */ + public function testInvalidPhpPackageName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid PHP package name "%s".', $name)); + + Assert::phpPackageName($name); + } + + public static function provideInvalidPhpPackageNames(): iterable + { + yield ['']; + yield ['twig']; + 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/Asset/ComponentTest.php b/src/Toolkit/tests/Asset/ComponentTest.php new file mode 100644 index 00000000000..a1babf6b135 --- /dev/null +++ b/src/Toolkit/tests/Asset/ComponentTest.php @@ -0,0 +1,121 @@ + + * + * 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\Component; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; + +final class ComponentTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $component = new Component('Button', [ + new File(FileType::Twig, 'templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $this->assertSame('Button', $component->name); + $this->assertCount(1, $component->files); + $this->assertInstanceOf(File::class, $component->files[0]); + $this->assertNull($component->doc); + $this->assertCount(0, $component->getDependencies()); + } + + public function testShouldFailIfComponentNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid component name "foobar".'); + + new Component('foobar', [ + new File(FileType::Twig, 'templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + } + + public function testShouldFailIfComponentHasNoFiles(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The component "Button" must have at least one file.'); + + new Component('Button', []); + } + + public function testCanAddAndGetDependencies(): void + { + $component = new Component('Button', [ + new File(FileType::Twig, 'templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $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'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); + } + + public function testShouldNotAddDuplicateComponentDependencies(): void + { + $component = new Component('Button', [ + new File(FileType::Twig, 'templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $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'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency4], $component->getDependencies()); + } + + public function testShouldReplacePhpPackageDependencyIfVersionIsHigher(): void + { + $component = new Component('Button', [ + new File(FileType::Twig, 'templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $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'))); + + 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'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency4], $component->getDependencies()); + } + + public function testShouldNotReplacePhpPackageDependencyIfVersionIsLower(): void + { + $component = new Component('Button', [ + new File(FileType::Twig, 'templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $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'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); + + $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/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/DebugKitCommandTest.php b/src/Toolkit/tests/Command/DebugKitCommandTest.php new file mode 100644 index 00000000000..e3f9d61e181 --- /dev/null +++ b/src/Toolkit/tests/Command/DebugKitCommandTest.php @@ -0,0 +1,42 @@ + + * + * 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 DebugKitCommandTest extends KernelTestCase +{ + use InteractsWithConsole; + + public function testShouldBeAbleToDebug(): void + { + $this->bootKernel(); + $this->consoleCommand(\sprintf('ux:toolkit:debug-kit %s', __DIR__.'/../../kits/shadcn')) + ->execute() + ->assertSuccessful() + // Kit details + ->assertOutputContains('Name Shadcn') + ->assertOutputContains('Homepage https://ux.symfony.com/components') + ->assertOutputContains('License MIT') + // A component details + ->assertOutputContains(<<<'EOF' ++--------------+----------------------- Component: "Avatar" --------------------------------------+ +| File(s) | templates/components/Avatar.html.twig (Twig) | +| Dependencies | Avatar:Image | +| | Avatar:Text | +| | tales-from-a-dev/twig-tailwind-extra | ++--------------+----------------------------------------------------------------------------------+ +EOF + ); + } +} diff --git a/src/Toolkit/tests/Command/InstallComponentCommandTest.php b/src/Toolkit/tests/Command/InstallComponentCommandTest.php new file mode 100644 index 00000000000..79e7e811c50 --- /dev/null +++ b/src/Toolkit/tests/Command/InstallComponentCommandTest.php @@ -0,0 +1,117 @@ + + * + * 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 Symfony\Component\Filesystem\Filesystem; +use Zenstruck\Console\Test\InteractsWithConsole; + +class InstallComponentCommandTest extends KernelTestCase +{ + use InteractsWithConsole; + + private Filesystem $filesystem; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + $this->tmpDir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_test_'); + $this->filesystem->remove($this->tmpDir); + $this->filesystem->mkdir($this->tmpDir); + } + + public function testShouldAbleToInstallComponentTableAndItsDependencies(): void + { + $expectedFiles = [ + 'Table.html.twig' => $this->tmpDir.'/Table.html.twig', + 'Table/Body.html.twig' => $this->tmpDir.'/Table/Body.html.twig', + 'Table/Caption.html.twig' => $this->tmpDir.'/Table/Caption.html.twig', + 'Table/Cell.html.twig' => $this->tmpDir.'/Table/Cell.html.twig', + 'Table/Footer.html.twig' => $this->tmpDir.'/Table/Footer.html.twig', + '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', + ]; + + foreach ($expectedFiles as $expectedFile) { + $this->assertFileDoesNotExist($expectedFile); + } + + $testCommand = $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) + ->execute() + ->assertSuccessful() + ->assertOutputContains('Installing component Table from the Shadcn UI kit...') + ->assertOutputContains('[OK] The component has been installed.') + ; + + // 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)); + } + } + + public function testShouldFailAndSuggestAlternativeComponentsWhenKitIsExplicit(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Table: --kit=shadcn --destination='.$destination) + ->execute() + ->assertFaulty() + ->assertOutputContains('[WARNING] The component "Table:" does not exist') + ->assertOutputContains('Possible alternatives: ') + ->assertOutputContains('"Table:Body"') + ->assertOutputContains('"Table:Caption"') + ->assertOutputContains('"Table:Cell"') + ->assertOutputContains('"Table:Footer"') + ->assertOutputContains('"Table:Head"') + ->assertOutputContains('"Table:Header"') + ->assertOutputContains('"Table:Row"') + ; + } + + public function testShouldFailWhenComponentDoesNotExist(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Unknown --destination='.$destination) + ->execute() + ->assertFaulty() + ->assertOutputContains('The component "Unknown" does not exist'); + } + + public function testShouldWarnWhenComponentFileAlreadyExistsInNonInteractiveMode(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Badge --destination='.$destination) + ->execute() + ->assertSuccessful(); + + $this->consoleCommand('ux:toolkit:install-component Badge --destination='.$destination) + ->execute() + ->assertFaulty() + ->assertOutputContains('[WARNING] The component has not been installed.') + ; + } +} diff --git a/src/Toolkit/tests/Dependency/ComponentDependencyTest.php b/src/Toolkit/tests/Dependency/ComponentDependencyTest.php new file mode 100644 index 00000000000..54dc3a42eb3 --- /dev/null +++ b/src/Toolkit/tests/Dependency/ComponentDependencyTest.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\ComponentDependency; + +final class ComponentDependencyTest extends TestCase +{ + public function testShouldBeInstantiable(): void + { + $dependency = new ComponentDependency('Table:Body'); + + $this->assertSame('Table:Body', $dependency->name); + $this->assertSame('Table:Body', (string) $dependency); + } + + public function testShouldFailIfComponentNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid component name "foobar".'); + + new ComponentDependency('foobar'); + } +} diff --git a/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php new file mode 100644 index 00000000000..8efb9f75501 --- /dev/null +++ b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php @@ -0,0 +1,39 @@ + + * + * 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\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; + +final class PhpPackageDependencyTest extends TestCase +{ + public function testShouldBeInstantiable(): void + { + $dependency = new PhpPackageDependency('twig/html-extra'); + $this->assertSame('twig/html-extra', $dependency->name); + $this->assertNull($dependency->constraintVersion); + $this->assertSame('twig/html-extra', (string) $dependency); + + $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); + } + + public function testShouldFailIfPackageNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $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/Dependency/VersionTest.php b/src/Toolkit/tests/Dependency/VersionTest.php new file mode 100644 index 00000000000..cc0c50f52ca --- /dev/null +++ b/src/Toolkit/tests/Dependency/VersionTest.php @@ -0,0 +1,35 @@ + + * + * 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\Version; + +final class VersionTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $version = new Version('1.2.3'); + + $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'))); + } +} diff --git a/src/Toolkit/tests/File/DocTest.php b/src/Toolkit/tests/File/DocTest.php new file mode 100644 index 00000000000..2cfb82df4b8 --- /dev/null +++ b/src/Toolkit/tests/File/DocTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\File; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\File\Doc; + +final class DocTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $doc = new Doc( + '# Basic Button + +```twig + + Click me + +```' + ); + + self::assertEquals('# Basic Button + +```twig + + Click me + +```', $doc->markdownContent); + } +} diff --git a/src/Toolkit/tests/File/FileTest.php b/src/Toolkit/tests/File/FileTest.php new file mode 100644 index 00000000000..8ee8a6ccb75 --- /dev/null +++ b/src/Toolkit/tests/File/FileTest.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\File; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; + +final class FileTest extends TestCase +{ + public function testShouldFailIfPathIsNotRelative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The path to the kit "%s" must be relative.', __FILE__.'/templates/components/Button.html.twig')); + + new File(FileType::Twig, __FILE__.'/templates/components/Button.html.twig', __FILE__.'Button.html.twig'); + } + + public function testShouldFailIfPathNameIsNotRelative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The path name "%s" must be relative.', __FILE__.'Button.html.twig')); + + new File(FileType::Twig, 'templates/components/Button.html.twig', __FILE__.'Button.html.twig'); + } + + public function testShouldFailIfPathNameIsNotASubpathOfPathToKit(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The relative path name "%s" must be a subpath of the relative path to the kit "%s".', 'foo/bar/Button.html.twig', 'templates/components/Button.html.twig')); + + new File(FileType::Twig, 'templates/components/Button.html.twig', 'foo/bar/Button.html.twig'); + } + + public function testCanInstantiateFile(): void + { + $file = new File(FileType::Twig, 'templates/components/Button.html.twig', 'Button.html.twig'); + + $this->assertSame(FileType::Twig, $file->type); + $this->assertSame('templates/components/Button.html.twig', $file->relativePathNameToKit); + $this->assertSame('Button.html.twig', $file->relativePathName); + $this->assertSame('templates/components/Button.html.twig (Twig)', (string) $file); + } + + public function testCanInstantiateFileWithSubComponent(): void + { + $file = new File(FileType::Twig, 'templates/components/Table/Body.html.twig', 'Table/Body.html.twig'); + + $this->assertSame(FileType::Twig, $file->type); + $this->assertSame('templates/components/Table/Body.html.twig', $file->relativePathNameToKit); + $this->assertSame('Table/Body.html.twig', $file->relativePathName); + $this->assertSame('templates/components/Table/Body.html.twig (Twig)', (string) $file); + } +} diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php new file mode 100644 index 00000000000..6734bb54ea5 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Fixtures; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +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 +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new TwigBundle(), + new TwigComponentBundle(), + new TwigExtraBundle(), + new UXIconsBundle(), + new TalesFromADevTwigExtraTailwindBundle(), + new UXToolkitBundle(), + ]; + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'S3CRET', + 'test' => true, + 'router' => ['utf8' => true], + 'secrets' => false, + 'http_method_override' => false, + 'php_errors' => ['log' => true], + 'property_access' => true, + 'http_client' => true, + 'handle_all_throwables' => true, + + ...(self::VERSION_ID >= 70300 ? [ + 'property_info' => ['with_constructor_extractor' => false], + ] : []), + ]); + + $container->extension('twig', [ + 'default_path' => __DIR__.'/../../kits', + ]); + + $container->extension('twig_component', [ + 'anonymous_template_directory' => 'components/', + 'defaults' => [], + ]); + + $container->services() + ->alias('ux_toolkit.kit.kit_factory', '.ux_toolkit.kit.kit_factory') + ->public() + + ->alias('ux_toolkit.kit.kit_synchronizer', '.ux_toolkit.kit.kit_synchronizer') + ->public() + + ->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/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..f23837787ff --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "With Circular Components Dependencies", + "description": "Kit used as a test fixture.", + "license": "MIT", + "homepage": "https://ux.symfony.com/" +} 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..4589ccfdc23 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "With Stimulus Controllers", + "description": "Kit used as a test fixture.", + "license": "MIT", + "homepage": "https://ux.symfony.com/" +} 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/Functional/ComponentsRenderingTest.php b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php new file mode 100644 index 00000000000..39978c8a5cd --- /dev/null +++ b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php @@ -0,0 +1,110 @@ + + * + * 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); + $renderedCode = $kitContextRunner->runForKit($kit, fn () => $template->render()); + + $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..4a79c14ce86 --- /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..f644e90cb4e --- /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..f1dbc69529a --- /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..b418978a959 --- /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/Installer/InstallerTest.php b/src/Toolkit/tests/Installer/InstallerTest.php new file mode 100644 index 00000000000..7386d581f63 --- /dev/null +++ b/src/Toolkit/tests/Installer/InstallerTest.php @@ -0,0 +1,130 @@ + + * + * 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 Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Installer\Installer; +use Symfony\UX\Toolkit\Kit\Kit; + +final class InstallerTest extends KernelTestCase +{ + private Filesystem $filesystem; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + $this->tmpDir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_test_'); + $this->filesystem->remove($this->tmpDir); + $this->filesystem->mkdir($this->tmpDir); + } + + public function testCanInstallComponent(): void + { + $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'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $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 + { + $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->installComponent($kit, $component, $this->tmpDir, false); + + $this->assertSame(0, $askedCount); + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $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); + } + + public function testCanInstallComponentIfForced(): void + { + $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->installComponent($kit, $component, $this->tmpDir, false); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $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(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + } + + public function testCanInstallComponentAndItsComponentDependencies(): void + { + $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 = [ + 'Table.html.twig' => $this->tmpDir.'/Table.html.twig', + 'Table/Body.html.twig' => $this->tmpDir.'/Table/Body.html.twig', + 'Table/Caption.html.twig' => $this->tmpDir.'/Table/Caption.html.twig', + 'Table/Cell.html.twig' => $this->tmpDir.'/Table/Cell.html.twig', + 'Table/Footer.html.twig' => $this->tmpDir.'/Table/Footer.html.twig', + '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); + } + + $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); + $this->assertSame(file_get_contents($expectedFile), file_get_contents(\sprintf('%s/templates/components/%s', $kit->path, $fileName))); + } + } + + private function createKit(string $kitName): Kit + { + 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..027f2433c8f --- /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/KitContextRunnerTest.php b/src/Toolkit/tests/Kit/KitContextRunnerTest.php new file mode 100644 index 00000000000..7cfe77c4094 --- /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('style="aspect-ratio:', $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/src/Toolkit/tests/Kit/KitFactoryTest.php b/src/Toolkit/tests/Kit/KitFactoryTest.php new file mode 100644 index 00000000000..59272656d14 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitFactoryTest.php @@ -0,0 +1,109 @@ + + * + * 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\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 +{ + public function testShouldFailIfPathIsNotAbsolute(): void + { + $kitFactory = $this->createKitFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Path "shadcn" is not absolute.'); + + $kitFactory->createKitFromAbsolutePath('shadcn'); + } + + public function testShouldFailIfKitDoesNotExist(): void + { + $kitFactory = $this->createKitFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Path "%s" does not exist.', __DIR__.'/../../kits/does-not-exist')); + + $kitFactory->createKitFromAbsolutePath(__DIR__.'/../../kits/does-not-exist'); + } + + public function testCanCreateShadKit(): void + { + $kitFactory = $this->createKitFactory(); + + $kit = $kitFactory->createKitFromAbsolutePath(__DIR__.'/../../kits/shadcn'); + + $this->assertNotNull($kit); + $this->assertNotEmpty($kit->getComponents()); + + $table = $kit->getComponent('Table'); + + $this->assertNotNull($table); + $this->assertNotEmpty($table->files); + $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'), + ], $table->getDependencies()); + $this->assertNotNull($table->doc); + $this->assertStringContainsString(<<<'EOF' +# Table + +A structured grid element that organizes data into rows and columns, supporting headers, captions, and footers. +EOF + , $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.kit_synchronizer')); + } +} diff --git a/src/Toolkit/tests/Kit/KitSynchronizerTest.php b/src/Toolkit/tests/Kit/KitSynchronizerTest.php new file mode 100644 index 00000000000..3b16710fd06 --- /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 new file mode 100644 index 00000000000..1696ba39c44 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitTest.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 PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; +use Symfony\UX\Toolkit\Kit\Kit; + +final class KitTest extends TestCase +{ + public function testShouldFailIfKitNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid kit name "-foobar".'); + + new Kit(__DIR__, '-foobar', 'https://example.com', 'MIT'); + } + + 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'); + } + + public function testCanAddComponentsToTheKit(): void + { + $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)); + + $this->assertCount(2, $kit->getComponents()); + } + + 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->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->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)); + + $this->assertSame('Table', $kit->getComponent('Table')->name); + $this->assertSame('Table:Row', $kit->getComponent('Table:Row')->name); + } + + public function testShouldReturnNullIfComponentIsNotFound(): void + { + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); + + $this->assertNull($kit->getComponent('Table:Cell')); + } +} diff --git a/src/Toolkit/tests/Registry/GitHubRegistryTest.php b/src/Toolkit/tests/Registry/GitHubRegistryTest.php new file mode 100644 index 00000000000..cffc454e602 --- /dev/null +++ b/src/Toolkit/tests/Registry/GitHubRegistryTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Registry; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\UX\Toolkit\Registry\GitHubRegistry; + +final class GitHubRegistryTest extends KernelTestCase +{ + private Filesystem $filesystem; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->filesystem = self::getContainer()->get('filesystem'); + $this->tmpDir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_test_'); + $this->filesystem->remove($this->tmpDir); + $this->filesystem->mkdir($this->tmpDir); + } + + public function testCanGetKitFromGithub(): void + { + $isHttpClientCalled = false; + $zipShadcnMain = $this->createZip('repo', 'shadcn', 'main'); + + $httpClient = new MockHttpClient(function (string $method, string $url) use ($zipShadcnMain, &$isHttpClientCalled) { + if ('GET' === $method && 'https://github.com/user/repo/archive/main.zip' === $url) { + $isHttpClientCalled = true; + + return new MockResponse( + file_get_contents($zipShadcnMain), + [ + 'http_code' => 200, + 'response_headers' => [ + 'content-type' => 'application/zip', + ], + ] + ); + } + }); + + $githubRegistry = new GitHubRegistry( + self::getContainer()->get('ux_toolkit.kit.kit_factory'), + $this->filesystem, + $httpClient, + ); + + $kit = $githubRegistry->getKit('github.com/user/repo'); + + $this->assertTrue($isHttpClientCalled); + $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')); + $this->assertFileExists(Path::join($kit->path, 'docs/components/Button.md')); + } + + public function testShouldThrowExceptionIfKitNotFound(): void + { + $githubRegistry = new GitHubRegistry( + self::getContainer()->get('ux_toolkit.kit.kit_factory'), + $this->filesystem, + new MockHttpClient(fn () => new MockResponse( + 'Not found', + [ + 'http_code' => 404, + ] + )), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to download the archive from "https://github.com/user/repo/archive/main.zip", ensure the repository exists and the version is valid.'); + + $githubRegistry->getKit('github.com/user/repo'); + } + + private function createZip(string $repo, string $kitName, string $version): string + { + $kitPath = Path::join(__DIR__, '..', '..', 'kits', $kitName); + if (!$this->filesystem->exists($kitPath)) { + throw new \RuntimeException(\sprintf('Kit "%s" not found in "%s".', $kitName, $kitPath)); + } + + $folderName = \sprintf('%s-%s', $repo, $version); + $zip = new \ZipArchive(); + $zip->open($zipPath = \sprintf('%s/%s.zip', $this->tmpDir, $folderName), \ZipArchive::CREATE); + foreach ((new Finder())->files()->in($kitPath) as $file) { + $zip->addFile($file->getPathname(), Path::join($folderName, $file->getRelativePathname())); + } + $zip->close(); + + return $zipPath; + } +} diff --git a/src/Toolkit/tests/Registry/LocalRegistryTest.php b/src/Toolkit/tests/Registry/LocalRegistryTest.php new file mode 100644 index 00000000000..b99cf8e8b70 --- /dev/null +++ b/src/Toolkit/tests/Registry/LocalRegistryTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Registry; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\LocalRegistry; + +final class LocalRegistryTest extends KernelTestCase +{ + public function testCanGetKit(): void + { + $localRegistry = new LocalRegistry( + self::getContainer()->get('ux_toolkit.kit.kit_factory'), + self::getContainer()->get('filesystem'), + self::getContainer()->getParameter('kernel.project_dir'), + ); + + $kit = $localRegistry->getKit('shadcn'); + + $this->assertInstanceOf(Kit::class, $kit); + $this->assertSame('Shadcn UI', $kit->name); + } +} diff --git a/src/Toolkit/tests/Registry/RegistryFactoryTest.php b/src/Toolkit/tests/Registry/RegistryFactoryTest.php new file mode 100644 index 00000000000..71ad4c4bf38 --- /dev/null +++ b/src/Toolkit/tests/Registry/RegistryFactoryTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Registry; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Registry\GitHubRegistry; +use Symfony\UX\Toolkit\Registry\LocalRegistry; + +final class RegistryFactoryTest extends KernelTestCase +{ + public static function provideRegistryNames(): array + { + return [ + ['shadcn', LocalRegistry::class], + ['foo-bar', LocalRegistry::class], + ['https://github.com/user/repo', GitHubRegistry::class], + ['https://github.com/user/repo:1.0.0', GitHubRegistry::class], + ['https://github.com/user/repo:2.x', GitHubRegistry::class], + ['github.com/user/repo', GitHubRegistry::class], + ['github.com/user/repo:1.0.0', GitHubRegistry::class], + ['github.com/user/repo:2.x', GitHubRegistry::class], + ]; + } + + /** + * @dataProvider provideRegistryNames + */ + public function testCanCreateRegistry(string $registryName, string $expectedRegistryClass): void + { + $registryFactory = self::getContainer()->get('ux_toolkit.registry.registry_factory'); + + $registry = $registryFactory->getForKit($registryName); + + $this->assertInstanceOf($expectedRegistryClass, $registry); + } + + public static function provideInvalidRegistryNames(): array + { + return [ + [''], + ['httpppps://github.com/user/repo@kit-name:2.x'], + ['github.com/user/repo:kit-name@1.0.0'], + ['github.com/user/repo@2.1'], + ]; + } + + /** + * @dataProvider provideInvalidRegistryNames + */ + public function testShouldFailIfRegistryIsNotFound(string $registryName): void + { + $registryFactory = self::getContainer()->get('ux_toolkit.registry.registry_factory'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The kit "%s" is not valid.', $registryName)); + + $registryFactory->getForKit($registryName); + } +} diff --git a/src/Toolkit/tests/UXToolkitBundleTest.php b/src/Toolkit/tests/UXToolkitBundleTest.php new file mode 100644 index 00000000000..f7b19becb83 --- /dev/null +++ b/src/Toolkit/tests/UXToolkitBundleTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\UXToolkitBundle; + +class UXToolkitBundleTest extends KernelTestCase +{ + public function testBundleBuildsSuccessfully(): void + { + self::bootKernel(); + $container = self::$kernel->getContainer(); + + $this->assertInstanceOf(UXToolkitBundle::class, $container->get('kernel')->getBundles()['UXToolkitBundle']); + } +} diff --git a/src/Toolkit/tests/bootstrap.php b/src/Toolkit/tests/bootstrap.php new file mode 100644 index 00000000000..519f959c9fa --- /dev/null +++ b/src/Toolkit/tests/bootstrap.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\ErrorHandler\ErrorHandler; +use Symfony\Component\Filesystem\Filesystem; + +require __DIR__.'/../vendor/autoload.php'; + +(new Filesystem())->remove(__DIR__.'/../var'); + +// @see https://github.com/symfony/symfony/issues/53812 +ErrorHandler::register(null, false); 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/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/CHANGELOG.md b/src/Turbo/CHANGELOG.md index 5a0f7d0f4f5..fef22f69cf1 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 2.24.0 + +- Add Twig Extensions for `meta` tags +- Add support for authentication to the EventSource via `turbo_stream_listen` + ## 2.22.0 - Add `` component 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/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/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/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 68b6f8723e5..e198d70647b 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 #} @@ -898,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). @@ -911,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:: @@ -1050,6 +1053,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/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 68eadd82079..1d4274107c8 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,20 @@ public function renderTurboStreamListen(Environment $env, $topic): string $controllerAttributes['topic'] = current($topics); } + if (isset($eventSourceOptions)) { + try { + $mercure = $this->twig->getExtension(MercureExtension::class); + + if ($eventSourceOptions['withCredentials'] ?? false) { + $eventSourceOptions['subscribe'] ??= $topics; + $controllerAttributes['withCredentials'] = true; + } + + $mercure->mercure($topics, $eventSourceOptions); + } 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 b44d993139f..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; @@ -22,34 +19,113 @@ */ final class TwigExtension extends AbstractExtension { - public function __construct( - private ContainerInterface $turboStreamListenRenderers, - private string $default, - ) { - } + 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 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']]), + 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']]), ]; } /** - * @param object|string|array $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 turboStreamListen(Environment $env, $topic, ?string $transport = null): string + public function turboRefreshesWith(string $method = self::REFRESH_METHOD_REPLACE, string $scroll = self::REFRESH_SCROLL_RESET): string { - $transport ??= $this->default; + return $this->turboRefreshMethod($method).$this->turboRefreshScroll($scroll); + } - if (!$this->turboStreamListenRenderers->has($transport)) { - throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); + /** + * 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)); } - if (\is_array($topic)) { - $topic = new TopicSet($topic); + 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 $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic); + return \sprintf('', $scroll); } } 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]); + } +} diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php index df735079570..96ef9c7ca0b 100644 --- a/src/TwigComponent/src/Twig/ComponentNode.php +++ b/src/TwigComponent/src/Twig/ComponentNode.php @@ -19,6 +19,7 @@ use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; use Twig\Node\NodeOutputInterface; +use Twig\Template; /** * @author Fabien Potencier @@ -154,16 +155,29 @@ public function compile(Compiler $compiler): void if ($useYield) { $compiler->write('yield from '); } - $compiler - ->write('$this->loadTemplate(') - ->string($this->getAttribute('embedded_template')) - ->raw(', ') - ->repr($this->getTemplateName()) - ->raw(', ') - ->repr($this->getTemplateLine()) - ->raw(', ') - ->string($this->getAttribute('embedded_index')) - ->raw(')'); + + // Support for Twig ^3.21 + if (method_exists(Template::class, 'load')) { + $compiler + ->write('$this->load(') + ->string($this->getAttribute('embedded_template')) + ->raw(', ') + ->repr($this->getTemplateLine()) + ->raw(', ') + ->string($this->getAttribute('embedded_index')) + ->raw(')'); + } else { + $compiler + ->write('$this->loadTemplate(') + ->string($this->getAttribute('embedded_template')) + ->raw(', ') + ->repr($this->getTemplateName()) + ->raw(', ') + ->repr($this->getTemplateLine()) + ->raw(', ') + ->string($this->getAttribute('embedded_index')) + ->raw(')'); + } if ($useYield) { $compiler->raw('->unwrap()->yield('); 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/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/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) 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" ], 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 00000000000..eaaccaf8d62 Binary files /dev/null and b/ux.symfony.com/assets/images/ux_packages/toolkit-1200x675.png differ diff --git a/ux.symfony.com/assets/images/ux_packages/toolkit.png b/ux.symfony.com/assets/images/ux_packages/toolkit.png new file mode 100644 index 00000000000..1902f34def0 Binary files /dev/null and b/ux.symfony.com/assets/images/ux_packages/toolkit.png differ diff --git a/ux.symfony.com/assets/styles/app.scss b/ux.symfony.com/assets/styles/app.scss index 27873192b1b..5b86e0251e6 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"; @@ -144,6 +144,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 +152,11 @@ $utilities: map-remove( @import "components/Terminal"; @import "components/TerminalCommand"; @import "components/ThemeSwitcher"; +@import "components/Toolkit_Tabs"; +@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/_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/_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; diff --git a/ux.symfony.com/assets/styles/components/_Toolkit_Tabs.scss b/ux.symfony.com/assets/styles/components/_Toolkit_Tabs.scss new file mode 100644 index 00000000000..d3871a5d65a --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_Toolkit_Tabs.scss @@ -0,0 +1,60 @@ +.Toolkit_Tabs { +} + +.Toolkit_TabHead { + display: flex; + flex-direction: row; + margin-bottom: 1rem +} + +.Toolkit_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; +} + +.Toolkit_TabControl.active { + border-color: var(--bs-secondary-color); +} + +.Toolkit_TabPanel { + position: relative; +} + +.Toolkit_TabPanel:not(.active) { + display: none; +} + +.Toolkit_TabPanel:has(.Toolkit_Preview) { + border: 1px solid var(--bs-border-color); + border-radius: .75rem +} + +.Toolkit_Loader { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + gap: .2rem; + position: absolute; + + svg { + animation: rotating 1s linear infinite; + } +} + +.Toolkit_Preview { + width: 100%; + transition: opacity .250s linear; + border-radius: .75rem; + opacity: 1; + + &.loading { + opacity: 0; + } +} 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 effedd67926..d9be8be8509 100644 --- a/ux.symfony.com/composer.json +++ b/ux.symfony.com/composer.json @@ -26,7 +26,7 @@ "symfony/mercure-bundle": "^0.3.9", "symfony/monolog-bundle": "^3.10", "symfony/notifier": "7.2.*", - "symfony/runtime": "7.2.*", + "symfony/runtime": "^7.2", "symfony/serializer": "7.2.*", "symfony/stimulus-bundle": "2.x-dev", "symfony/translation": "7.2.*", @@ -46,6 +46,7 @@ "symfony/ux-svelte": "2.x-dev", "symfony/ux-swup": "2.x-dev", "symfony/ux-toggle-password": "2.x-dev", + "symfony/ux-toolkit": "2.x-dev", "symfony/ux-translator": "2.x-dev", "symfony/ux-turbo": "2.x-dev", "symfony/ux-twig-component": "2.x-dev", @@ -55,6 +56,8 @@ "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", "twig/extra-bundle": "^3.17", @@ -133,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 ee947d5bfbf..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": "7a653290bf7adb58131f9455ff7b243e", + "content-hash": "4c63747018e3269a9e8d6adcd246355b", "packages": [ { "name": "composer/semver", @@ -1489,6 +1489,73 @@ }, "time": "2024-10-21T18:21:57+00:00" }, + { + "name": "gehrisandro/tailwind-merge-php", + "version": "v1.1.2", + "source": { + "type": "git", + "url": "https://github.com/gehrisandro/tailwind-merge-php.git", + "reference": "dc11b9d4a625dd5be885900e5ef14c3efa260277" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gehrisandro/tailwind-merge-php/zipball/dc11b9d4a625dd5be885900e5ef14c3efa260277", + "reference": "dc11b9d4a625dd5be885900e5ef14c3efa260277", + "shasum": "" + }, + "require": { + "php": "^8.1.0", + "psr/simple-cache": "^3.0" + }, + "require-dev": { + "laravel/pint": "^1.13.8", + "nunomaduro/collision": "^7.10", + "pestphp/pest": "^v2.24.0", + "pestphp/pest-plugin-arch": "^2.6", + "pestphp/pest-plugin-mock": "^2.0.0", + "pestphp/pest-plugin-type-coverage": "^2.8", + "phpstan/phpstan": "^1.10.55", + "rector/rector": "^1.0.5", + "symfony/var-dumper": "^6.4.2" + }, + "type": "library", + "autoload": { + "files": [ + "src/TailwindMerge.php" + ], + "psr-4": { + "TailwindMerge\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "TailwindMerge for PHP merges multiple Tailwind CSS classes by automatically resolving conflicts between them", + "keywords": [ + "classes", + "merge", + "php", + "tailwindcss" + ], + "support": { + "issues": "https://github.com/gehrisandro/tailwind-merge-php/issues", + "source": "https://github.com/gehrisandro/tailwind-merge-php/tree/v1.1.2" + }, + "funding": [ + { + "url": "https://github.com/gehrisandro", + "type": "github" + } + ], + "time": "2024-05-21T17:32:42+00:00" + }, { "name": "guzzlehttp/psr7", "version": "2.7.0", @@ -2664,6 +2731,57 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -2858,16 +2976,16 @@ }, { "name": "symfony/cache", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "2c926bc348184b4b235f2200fcbe8fcf3c8c5b8a" + "reference": "8d773a575e446de220dca03d600b2d8e1c1c10ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/2c926bc348184b4b235f2200fcbe8fcf3c8c5b8a", - "reference": "2c926bc348184b4b235f2200fcbe8fcf3c8c5b8a", + "url": "https://api.github.com/repos/symfony/cache/zipball/8d773a575e446de220dca03d600b2d8e1c1c10ec", + "reference": "8d773a575e446de220dca03d600b2d8e1c1c10ec", "shasum": "" }, "require": { @@ -2936,7 +3054,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.2.0" + "source": "https://github.com/symfony/cache/tree/v7.2.3" }, "funding": [ { @@ -2952,7 +3070,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T15:21:05+00:00" + "time": "2025-01-27T11:08:17+00:00" }, { "name": "symfony/cache-contracts", @@ -2974,12 +3092,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -3032,16 +3150,16 @@ }, { "name": "symfony/config", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "bcd3c4adf0144dee5011bb35454728c38adec055" + "reference": "7716594aaae91d9141be080240172a92ecca4d44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/bcd3c4adf0144dee5011bb35454728c38adec055", - "reference": "bcd3c4adf0144dee5011bb35454728c38adec055", + "url": "https://api.github.com/repos/symfony/config/zipball/7716594aaae91d9141be080240172a92ecca4d44", + "reference": "7716594aaae91d9141be080240172a92ecca4d44", "shasum": "" }, "require": { @@ -3087,7 +3205,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.2.0" + "source": "https://github.com/symfony/config/tree/v7.2.3" }, "funding": [ { @@ -3103,20 +3221,20 @@ "type": "tidelift" } ], - "time": "2024-11-04T11:36:24+00:00" + "time": "2025-01-22T12:07:01+00:00" }, { "name": "symfony/console", - "version": "v7.2.0", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "23c8aae6d764e2bae02d2a99f7532a7f6ed619cf" + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/23c8aae6d764e2bae02d2a99f7532a7f6ed619cf", - "reference": "23c8aae6d764e2bae02d2a99f7532a7f6ed619cf", + "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", "shasum": "" }, "require": { @@ -3180,7 +3298,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.0" + "source": "https://github.com/symfony/console/tree/v7.2.1" }, "funding": [ { @@ -3196,20 +3314,20 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2024-12-11T03:49:26+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "a475747af1a1c98272a5471abc35f3da81197c5d" + "reference": "1d321c4bc3fe926fd4c38999a4c9af4f5d61ddfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a475747af1a1c98272a5471abc35f3da81197c5d", - "reference": "a475747af1a1c98272a5471abc35f3da81197c5d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/1d321c4bc3fe926fd4c38999a4c9af4f5d61ddfc", + "reference": "1d321c4bc3fe926fd4c38999a4c9af4f5d61ddfc", "shasum": "" }, "require": { @@ -3260,7 +3378,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.2.0" + "source": "https://github.com/symfony/dependency-injection/tree/v7.2.3" }, "funding": [ { @@ -3276,7 +3394,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T15:45:00+00:00" + "time": "2025-01-17T10:56:55+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3297,12 +3415,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -3530,16 +3648,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "672b3dd1ef8b87119b446d67c58c106c43f965fe" + "reference": "959a74d044a6db21f4caa6d695648dcb5584cb49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/672b3dd1ef8b87119b446d67c58c106c43f965fe", - "reference": "672b3dd1ef8b87119b446d67c58c106c43f965fe", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/959a74d044a6db21f4caa6d695648dcb5584cb49", + "reference": "959a74d044a6db21f4caa6d695648dcb5584cb49", "shasum": "" }, "require": { @@ -3585,7 +3703,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.2.0" + "source": "https://github.com/symfony/error-handler/tree/v7.2.3" }, "funding": [ { @@ -3601,7 +3719,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T15:35:02+00:00" + "time": "2025-01-07T09:39:55+00:00" }, { "name": "symfony/event-dispatcher", @@ -3703,12 +3821,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -3891,16 +4009,16 @@ }, { "name": "symfony/finder", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" + "reference": "87a71856f2f56e4100373e92529eed3171695cfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", + "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", + "reference": "87a71856f2f56e4100373e92529eed3171695cfb", "shasum": "" }, "require": { @@ -3935,7 +4053,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.0" + "source": "https://github.com/symfony/finder/tree/v7.2.2" }, "funding": [ { @@ -3951,7 +4069,7 @@ "type": "tidelift" } ], - "time": "2024-10-23T06:56:12+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/flex", @@ -4120,16 +4238,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "a8d0da4110fe643ab3cde7c938a03e222fe787c6" + "reference": "d37a43dd0b2079605fcab3056dac71934f06dc0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a8d0da4110fe643ab3cde7c938a03e222fe787c6", - "reference": "a8d0da4110fe643ab3cde7c938a03e222fe787c6", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/d37a43dd0b2079605fcab3056dac71934f06dc0f", + "reference": "d37a43dd0b2079605fcab3056dac71934f06dc0f", "shasum": "" }, "require": { @@ -4250,7 +4368,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.2.0" + "source": "https://github.com/symfony/framework-bundle/tree/v7.2.3" }, "funding": [ { @@ -4266,7 +4384,7 @@ "type": "tidelift" } ], - "time": "2024-11-20T16:27:35+00:00" + "time": "2025-01-29T07:13:55+00:00" }, { "name": "symfony/http-client", @@ -4365,16 +4483,16 @@ }, { "name": "symfony/http-client-contracts", - "version": "v3.5.1", + "version": "v3.5.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "c2f3ad828596624ca39ea40f83617ef51ca8bbf9" + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/c2f3ad828596624ca39ea40f83617ef51ca8bbf9", - "reference": "c2f3ad828596624ca39ea40f83617ef51ca8bbf9", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", "shasum": "" }, "require": { @@ -4423,7 +4541,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" }, "funding": [ { @@ -4439,20 +4557,20 @@ "type": "tidelift" } ], - "time": "2024-11-25T12:02:18+00:00" + "time": "2024-12-07T08:49:48+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "e88a66c3997859532bc2ddd6dd8f35aba2711744" + "reference": "ee1b504b8926198be89d05e5b6fc4c3810c090f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e88a66c3997859532bc2ddd6dd8f35aba2711744", - "reference": "e88a66c3997859532bc2ddd6dd8f35aba2711744", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee1b504b8926198be89d05e5b6fc4c3810c090f0", + "reference": "ee1b504b8926198be89d05e5b6fc4c3810c090f0", "shasum": "" }, "require": { @@ -4501,7 +4619,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.2.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.2.3" }, "funding": [ { @@ -4517,20 +4635,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T18:58:46+00:00" + "time": "2025-01-17T10:56:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6b4722a25e0aed1ccb4914b9bcbd493cc4676b4d" + "reference": "caae9807f8e25a9b43ce8cc6fafab6cf91f0cc9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6b4722a25e0aed1ccb4914b9bcbd493cc4676b4d", - "reference": "6b4722a25e0aed1ccb4914b9bcbd493cc4676b4d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/caae9807f8e25a9b43ce8cc6fafab6cf91f0cc9b", + "reference": "caae9807f8e25a9b43ce8cc6fafab6cf91f0cc9b", "shasum": "" }, "require": { @@ -4615,7 +4733,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.2.0" + "source": "https://github.com/symfony/http-kernel/tree/v7.2.3" }, "funding": [ { @@ -4631,7 +4749,7 @@ "type": "tidelift" } ], - "time": "2024-11-29T08:42:40+00:00" + "time": "2025-01-29T07:40:13+00:00" }, { "name": "symfony/intl", @@ -4956,16 +5074,16 @@ }, { "name": "symfony/mime", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "cc84a4b81f62158c3846ac7ff10f696aae2b524d" + "reference": "2fc3b4bd67e4747e45195bc4c98bea4628476204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/cc84a4b81f62158c3846ac7ff10f696aae2b524d", - "reference": "cc84a4b81f62158c3846ac7ff10f696aae2b524d", + "url": "https://api.github.com/repos/symfony/mime/zipball/2fc3b4bd67e4747e45195bc4c98bea4628476204", + "reference": "2fc3b4bd67e4747e45195bc4c98bea4628476204", "shasum": "" }, "require": { @@ -5020,7 +5138,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.2.0" + "source": "https://github.com/symfony/mime/tree/v7.2.3" }, "funding": [ { @@ -5036,7 +5154,7 @@ "type": "tidelift" } ], - "time": "2024-11-23T09:19:39+00:00" + "time": "2025-01-27T11:08:17+00:00" }, { "name": "symfony/monolog-bridge", @@ -5890,16 +6008,16 @@ }, { "name": "symfony/property-access", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "3ae42efba01e45aaedecf5c93c8d6a3ab3a82276" + "reference": "b28732e315d81fbec787f838034de7d6c9b2b902" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/3ae42efba01e45aaedecf5c93c8d6a3ab3a82276", - "reference": "3ae42efba01e45aaedecf5c93c8d6a3ab3a82276", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b28732e315d81fbec787f838034de7d6c9b2b902", + "reference": "b28732e315d81fbec787f838034de7d6c9b2b902", "shasum": "" }, "require": { @@ -5946,7 +6064,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.2.0" + "source": "https://github.com/symfony/property-access/tree/v7.2.3" }, "funding": [ { @@ -5962,31 +6080,33 @@ "type": "tidelift" } ], - "time": "2024-09-26T12:28:35+00:00" + "time": "2025-01-17T10:56:55+00:00" }, { "name": "symfony/property-info", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "b00580d9d7c9654e1df95df85105d0da67418b3f" + "reference": "dedb118fd588a92f226b390250b384d25f4192fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/b00580d9d7c9654e1df95df85105d0da67418b3f", - "reference": "b00580d9d7c9654e1df95df85105d0da67418b3f", + "url": "https://api.github.com/repos/symfony/property-info/zipball/dedb118fd588a92f226b390250b384d25f4192fe", + "reference": "dedb118fd588a92f226b390250b384d25f4192fe", "shasum": "" }, "require": { "php": ">=8.2", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "^7.1" + "symfony/type-info": "~7.1.9|^7.2.2" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/dependency-injection": "<6.4" + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" }, "require-dev": { "phpdocumentor/reflection-docblock": "^5.2", @@ -6029,7 +6149,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.2.0" + "source": "https://github.com/symfony/property-info/tree/v7.2.3" }, "funding": [ { @@ -6045,20 +6165,20 @@ "type": "tidelift" } ], - "time": "2024-11-27T09:50:52+00:00" + "time": "2025-01-27T11:08:17+00:00" }, { "name": "symfony/routing", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e" + "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e10a2450fa957af6c448b9b93c9010a4e4c0725e", - "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e", + "url": "https://api.github.com/repos/symfony/routing/zipball/ee9a67edc6baa33e5fae662f94f91fd262930996", + "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996", "shasum": "" }, "require": { @@ -6110,7 +6230,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.2.0" + "source": "https://github.com/symfony/routing/tree/v7.2.3" }, "funding": [ { @@ -6126,20 +6246,20 @@ "type": "tidelift" } ], - "time": "2024-11-25T11:08:51+00:00" + "time": "2025-01-17T10:56:55+00:00" }, { "name": "symfony/runtime", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "2c350568f3eaccb25fbbbf962bd67cde273121a7" + "reference": "8e8d09bd69b7f6c0260dd3d58f37bd4fbdeab5ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/2c350568f3eaccb25fbbbf962bd67cde273121a7", - "reference": "2c350568f3eaccb25fbbbf962bd67cde273121a7", + "url": "https://api.github.com/repos/symfony/runtime/zipball/8e8d09bd69b7f6c0260dd3d58f37bd4fbdeab5ad", + "reference": "8e8d09bd69b7f6c0260dd3d58f37bd4fbdeab5ad", "shasum": "" }, "require": { @@ -6189,7 +6309,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.2.0" + "source": "https://github.com/symfony/runtime/tree/v7.2.3" }, "funding": [ { @@ -6205,20 +6325,20 @@ "type": "tidelift" } ], - "time": "2024-11-06T11:43:25+00:00" + "time": "2024-12-29T21:39:47+00:00" }, { "name": "symfony/serializer", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0" + "reference": "320f30beb419ce4f96363ada5e225c41f1ef08ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0", - "reference": "3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0", + "url": "https://api.github.com/repos/symfony/serializer/zipball/320f30beb419ce4f96363ada5e225c41f1ef08ab", + "reference": "320f30beb419ce4f96363ada5e225c41f1ef08ab", "shasum": "" }, "require": { @@ -6287,7 +6407,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.2.0" + "source": "https://github.com/symfony/serializer/tree/v7.2.3" }, "funding": [ { @@ -6303,7 +6423,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T15:21:05+00:00" + "time": "2025-01-29T07:13:55+00:00" }, { "name": "symfony/service-contracts", @@ -6329,12 +6449,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -6721,12 +6841,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -6782,16 +6902,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "9958f5a5b6640734fe4b24c18897191f77a02c61" + "reference": "29e4c66de9618e67dc1f5f13bc667aca2a228f1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/9958f5a5b6640734fe4b24c18897191f77a02c61", - "reference": "9958f5a5b6640734fe4b24c18897191f77a02c61", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/29e4c66de9618e67dc1f5f13bc667aca2a228f1e", + "reference": "29e4c66de9618e67dc1f5f13bc667aca2a228f1e", "shasum": "" }, "require": { @@ -6872,7 +6992,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.2.0" + "source": "https://github.com/symfony/twig-bridge/tree/v7.2.2" }, "funding": [ { @@ -6888,7 +7008,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T14:26:33+00:00" + "time": "2024-12-19T14:25:03+00:00" }, { "name": "symfony/twig-bundle", @@ -6976,29 +7096,24 @@ }, { "name": "symfony/type-info", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "e0bfd95bceb3886c59487828537691aecb7d9c6b" + "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/e0bfd95bceb3886c59487828537691aecb7d9c6b", - "reference": "e0bfd95bceb3886c59487828537691aecb7d9c6b", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3b5a17470fff0034f25fd4287cbdaa0010d2f749", + "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749", "shasum": "" }, "require": { "php": ">=8.2", "psr/container": "^1.1|^2.0" }, - "conflict": { - "phpstan/phpdoc-parser": "<1.0", - "symfony/dependency-injection": "<6.4" - }, "require-dev": { - "phpstan/phpdoc-parser": "^1.0|^2.0", - "symfony/dependency-injection": "^6.4|^7.0" + "phpstan/phpdoc-parser": "^1.0|^2.0" }, "type": "library", "autoload": { @@ -7036,7 +7151,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.2.0" + "source": "https://github.com/symfony/type-info/tree/v7.2.2" }, "funding": [ { @@ -7052,7 +7167,7 @@ "type": "tidelift" } ], - "time": "2024-11-18T09:51:31+00:00" + "time": "2024-12-20T13:38:37+00:00" }, { "name": "symfony/uid", @@ -8274,6 +8389,87 @@ ], "time": "2024-12-05T16:05:57+00:00" }, + { + "name": "symfony/ux-toolkit", + "version": "2.x-dev", + "dist": { + "type": "path", + "url": "../src/Toolkit", + "reference": "f0345541d15b0781efce35d5610f3d22622d0a76" + }, + "require": { + "php": ">=8.3", + "symfony/console": "^7.2", + "symfony/filesystem": "^7.2", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/ux-twig-component": "^2.22", + "twig/extra-bundle": "^3.19|^4.0", + "twig/html-extra": "^3.19", + "twig/twig": "^2.12|^3.0" + }, + "conflict": { + "symfony/ux-twig-component": "<2.21" + }, + "require-dev": { + "symfony/finder": "6.4|^7.0", + "symfony/http-client": "6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/stopwatch": "^7.2", + "tales-from-a-dev/twig-tailwind-extra": "^0.3.0", + "zenstruck/console-test": "^1.7" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Toolkit\\": "src" + }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Toolkit\\Tests\\": "tests/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Jean-François Lépine", + "email": "lepinejeanfrancois@gmail.com" + }, + { + "name": "Simon André", + "email": "smn.andre@gmail.com" + } + ], + "description": "Twig Toolkit for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "transport-options": { + "symlink": false, + "relative": true + } + }, { "name": "symfony/ux-translator", "version": "2.x-dev", @@ -8456,12 +8652,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "9b347f6ca2d9e18cee630787f0a6aa453982bf18" + "reference": "f29033b95e93aea2d498dc40eac185ed14b07800" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/9b347f6ca2d9e18cee630787f0a6aa453982bf18", - "reference": "9b347f6ca2d9e18cee630787f0a6aa453982bf18", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/f29033b95e93aea2d498dc40eac185ed14b07800", + "reference": "f29033b95e93aea2d498dc40eac185ed14b07800", "shasum": "" }, "require": { @@ -8516,7 +8712,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v2.22.1" + "source": "https://github.com/symfony/ux-twig-component/tree/2.x" }, "funding": [ { @@ -8532,7 +8728,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T18:05:50+00:00" + "time": "2025-01-25T02:19:26+00:00" }, { "name": "symfony/ux-typed", @@ -8782,16 +8978,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.2.0", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c" + "reference": "82b478c69745d8878eb60f9a049a4d584996f73a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c6a22929407dec8765d6e2b6ff85b800b245879c", - "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a", + "reference": "82b478c69745d8878eb60f9a049a4d584996f73a", "shasum": "" }, "require": { @@ -8845,7 +9041,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.2.3" }, "funding": [ { @@ -8861,7 +9057,7 @@ "type": "tidelift" } ], - "time": "2024-11-08T15:48:14+00:00" + "time": "2025-01-17T11:39:41+00:00" }, { "name": "symfony/var-exporter", @@ -9203,6 +9399,117 @@ }, "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", + "source": { + "type": "git", + "url": "https://github.com/tales-from-a-dev/twig-tailwind-extra.git", + "reference": "a3cb86414dd5810740cf91966bc1cf10047ce8ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tales-from-a-dev/twig-tailwind-extra/zipball/a3cb86414dd5810740cf91966bc1cf10047ce8ef", + "reference": "a3cb86414dd5810740cf91966bc1cf10047ce8ef", + "shasum": "" + }, + "require": { + "gehrisandro/tailwind-merge-php": "^1.0", + "php": ">=8.2", + "symfony/cache": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "twig/twig": "^3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.38", + "symfony/phpunit-bridge": "^6.4 || ^7.0" + }, + "type": "twig", + "autoload": { + "psr-4": { + "TalesFromADev\\Twig\\Extra\\Tailwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Monteil", + "email": "monteil.romain@gmail.com" + } + ], + "description": "A Twig extension for Tailwind", + "homepage": "https://github.com/tales-from-a-dev/twig-tailwind-extra", + "keywords": [ + "extension", + "symfony", + "tailwind", + "twig" + ], + "support": { + "issues": "https://github.com/tales-from-a-dev/twig-tailwind-extra/issues", + "source": "https://github.com/tales-from-a-dev/twig-tailwind-extra/tree/v0.3.0" + }, + "time": "2024-08-07T23:27:08+00:00" + }, { "name": "tempest/highlight", "version": "2.11.2", @@ -9313,7 +9620,7 @@ }, { "name": "twig/extra-bundle", - "version": "v3.17.0", + "version": "v3.19.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", @@ -9371,7 +9678,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.17.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.19.0" }, "funding": [ { @@ -9387,16 +9694,16 @@ }, { "name": "twig/html-extra", - "version": "v3.17.0", + "version": "v3.19.0", "source": { "type": "git", "url": "https://github.com/twigphp/html-extra.git", - "reference": "2086023d3ffc4bae2b1115f715d17f97fd013665" + "reference": "c63b28e192c1b7c15bb60f81d2e48b140846239a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/html-extra/zipball/2086023d3ffc4bae2b1115f715d17f97fd013665", - "reference": "2086023d3ffc4bae2b1115f715d17f97fd013665", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/c63b28e192c1b7c15bb60f81d2e48b140846239a", + "reference": "c63b28e192c1b7c15bb60f81d2e48b140846239a", "shasum": "" }, "require": { @@ -9439,7 +9746,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/html-extra/tree/v3.17.0" + "source": "https://github.com/twigphp/html-extra/tree/v3.19.0" }, "funding": [ { @@ -9451,7 +9758,7 @@ "type": "tidelift" } ], - "time": "2024-09-30T06:41:48+00:00" + "time": "2024-12-29T10:29:59+00:00" }, { "name": "twig/intl-extra", @@ -9658,16 +9965,16 @@ }, { "name": "twig/twig", - "version": "v3.17.0", + "version": "v3.19.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "d3a64b742a5e74c57e3964d766e1032982145872" + "reference": "d4f8c2b86374f08efc859323dbcd95c590f7124e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/d3a64b742a5e74c57e3964d766e1032982145872", - "reference": "d3a64b742a5e74c57e3964d766e1032982145872", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/d4f8c2b86374f08efc859323dbcd95c590f7124e", + "reference": "d4f8c2b86374f08efc859323dbcd95c590f7124e", "shasum": "" }, "require": { @@ -9722,7 +10029,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.17.0" + "source": "https://github.com/twigphp/Twig/tree/v3.19.0" }, "funding": [ { @@ -9734,7 +10041,7 @@ "type": "tidelift" } ], - "time": "2024-12-10T15:19:11+00:00" + "time": "2025-01-29T07:06:14+00:00" } ], "packages-dev": [ @@ -12582,6 +12889,7 @@ "symfony/ux-svelte": 20, "symfony/ux-swup": 20, "symfony/ux-toggle-password": 20, + "symfony/ux-toolkit": 20, "symfony/ux-translator": 20, "symfony/ux-turbo": 20, "symfony/ux-twig-component": 20, diff --git a/ux.symfony.com/config/bundles.php b/ux.symfony.com/config/bundles.php index c9bb8d7be97..ac7e54aa723 100644 --- a/ux.symfony.com/config/bundles.php +++ b/ux.symfony.com/config/bundles.php @@ -32,4 +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 => ['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/services.yaml b/ux.symfony.com/config/services.yaml index 95f38b16b4c..16090c23197 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.registry_factory: + alias: '.ux_toolkit.registry.registry_factory' diff --git a/ux.symfony.com/cookbook/component_architecture.md b/ux.symfony.com/cookbook/component_architecture.md index d178c87d4ad..dd03081f095 100644 --- a/ux.symfony.com/cookbook/component_architecture.md +++ b/ux.symfony.com/cookbook/component_architecture.md @@ -42,15 +42,15 @@ So here you can see we have an `Alert` component that itself uses an Icon compon Or you can compose with the following syntax: ```twig - + - + ``` -So here we have a `Card` component, and we provide the content of this component with two other components. +So here we have a `Alert` component, and we provide the content of this component with two other components. ### Independence 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..05301ffc862 100644 --- a/ux.symfony.com/src/Controller/SitemapController.php +++ b/ux.symfony.com/src/Controller/SitemapController.php @@ -12,6 +12,7 @@ namespace App\Controller; 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 +25,7 @@ final class SitemapController extends AbstractController public function __construct( private readonly UxPackageRepository $uxPackageRepository, private readonly LiveDemoRepository $liveDemoRepository, + private readonly ToolkitService $toolkitService, ) { } @@ -62,6 +64,15 @@ private function getSitemapUrls(): iterable foreach ($this->liveDemoRepository->findAll() as $demo) { yield $this->generateAbsoluteUrl($demo->getRoute()); } + + // Toolkit kits + foreach ($this->toolkitService->getKits() as $kitId => $kit) { + yield $this->generateAbsoluteUrl('app_toolkit_kit', ['kitId' => $kitId]); + + foreach ($this->toolkitService->getDocumentableComponents($kit) as $component) { + yield $this->generateAbsoluteUrl('app_toolkit_component', ['kitId' => $kitId, '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..f0bb2599916 --- /dev/null +++ b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php @@ -0,0 +1,104 @@ + + * + * 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\ToolkitKitId; +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\Kit\KitContextRunner; + +class ComponentsController extends AbstractController +{ + public function __construct( + private ToolkitService $toolkitService, + private UxPackageRepository $uxPackageRepository, + ) { + } + + #[Route('/toolkit/kits/{kit}/components/')] + public function listComponents(ToolkitKitId $kit): Response + { + // TODO: implementing listing in the future :D + + return $this->redirectToRoute('app_toolkit_kit', [ + 'kit' => $kit->value, + ], Response::HTTP_FOUND); + } + + #[Route('/toolkit/kits/{kitId}/components/{componentName}', name: 'app_toolkit_component')] + public function showComponent(ToolkitKitId $kitId, string $componentName): Response + { + $kit = $this->toolkitService->getKit($kitId); + if (null === $component = $kit->getComponent($componentName)) { + throw $this->createNotFoundException(\sprintf('Component "%s" not found', $componentName)); + } + + $package = $this->uxPackageRepository->find('toolkit'); + + return $this->render('toolkit/component.html.twig', [ + 'package' => $package, + 'components' => $this->toolkitService->getDocumentableComponents($kit), + 'kit' => $kit, + 'kit_id' => $kitId, + 'component' => $component, + ]); + } + + #[Route('/toolkit/component_preview', name: 'app_toolkit_component_preview')] + public function previewComponent( + Request $request, + #[MapQueryParameter] ToolkitKitId $kitId, + #[MapQueryParameter] string $code, + #[MapQueryParameter] string $height, + UriSigner $uriSigner, + \Twig\Environment $twig, + #[Autowire(service: 'ux_toolkit.kit.kit_context_runner')] + KitContextRunner $kitContextRunner, + #[Autowire(service: 'profiler')] + ?Profiler $profiler, + ): Response { + if (!$uriSigner->checkRequest($request)) { + throw new BadRequestHttpException('Request is invalid.'); + } + + $profiler?->disable(); + + $kit = $this->toolkitService->getKit($kitId); + + $template = $twig->createTemplate(<< + + + Preview + + {{ importmap('toolkit-{$kitId->value}') }} + + {$code} + +HTML); + + return new Response( + $kitContextRunner->runForKit($kit, fn () => $twig->render($template)), + Response::HTTP_OK, + ['X-Robots-Tag' => 'noindex, nofollow'] + ); + } +} 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..ea8f9ad3d72 --- /dev/null +++ b/ux.symfony.com/src/Controller/Toolkit/KitsController.php @@ -0,0 +1,42 @@ + + * + * 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\ToolkitKitId; +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 +{ + #[Route('/toolkit/kits')] + public function listKits(): Response + { + return $this->redirectToRoute('app_toolkit', ['_fragment' => 'kits']); + } + + #[Route('/toolkit/kits/{kitId}', name: 'app_toolkit_kit')] + public function showKit(ToolkitKitId $kitId, ToolkitService $toolkitService, UxPackageRepository $uxPackageRepository): Response + { + $kit = $toolkitService->getKit($kitId); + $package = $uxPackageRepository->find('toolkit'); + + return $this->render('toolkit/kit.html.twig', [ + 'package' => $package, + 'kit' => $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 new file mode 100644 index 00000000000..2d75d231509 --- /dev/null +++ b/ux.symfony.com/src/Controller/UxPackage/ToolkitController.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 App\Controller\UxPackage; + +use App\Enum\ToolkitKitId; +use App\Service\Toolkit\ToolkitService; +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\Component\Routing\Generator\UrlGeneratorInterface; + +class ToolkitController extends AbstractController +{ + #[Route('/toolkit', name: 'app_toolkit')] + 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', [ + 'kitId' => ToolkitKitId::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)); + + return $this->render('ux_packages/toolkit.html.twig', [ + 'package' => $package, + 'kits' => $toolkitService->getKits(), + 'demoPreviewUrl' => $demoPreviewUrl, + 'demoPreviewHeight' => $demoPreviewHeight, + ]); + } +} diff --git a/ux.symfony.com/src/Enum/ToolkitKitId.php b/ux.symfony.com/src/Enum/ToolkitKitId.php new file mode 100644 index 00000000000..03911edcd8c --- /dev/null +++ b/ux.symfony.com/src/Enum/ToolkitKitId.php @@ -0,0 +1,24 @@ + + * + * 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/Model/UxPackage.php b/ux.symfony.com/src/Model/UxPackage.php index bba81f52e5c..723d0882127 100644 --- a/ux.symfony.com/src/Model/UxPackage.php +++ b/ux.symfony.com/src/Model/UxPackage.php @@ -29,6 +29,7 @@ public function __construct( private ?string $createString = null, private ?string $imageFileName = null, private ?string $composerName = null, + private bool $isDevDependency = false, ) { } @@ -79,6 +80,10 @@ public function getComposerName(): string public function getComposerRequireCommand(): string { + 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..9ed9c4991b1 100644 --- a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php +++ b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php @@ -11,12 +11,14 @@ 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; use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\Mention\MentionExtension; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Tempest\Highlight\CommonMark\HighlightExtension; /** * @author Kevin Bond @@ -24,6 +26,11 @@ #[AsDecorator('twig.markdown.league_common_mark_converter_factory')] final class ConverterFactory { + public function __construct( + private readonly ToolkitService $toolkitService, + ) { + } + public function __invoke(): CommonMarkConverter { $converter = new CommonMarkConverter([ @@ -47,8 +54,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->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 new file mode 100644 index 00000000000..522bd4a4d8d --- /dev/null +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -0,0 +1,70 @@ + + * + * 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 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 Tempest\Highlight\Highlighter; +use Tempest\Highlight\WebTheme; + +final readonly class CodeBlockRenderer implements NodeRendererInterface +{ + public function __construct( + private ToolkitService $toolkitService, + ) { + } + + #[\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) : []; + $kitId = ToolkitKitId::tryFrom($options['kit'] ?? null); + $preview = $options['preview'] ?? false; + + $output = $this->highlightCode($language, $code = $node->getLiteral()); + + if ($kitId && $preview) { + $output = $this->toolkitService->renderComponentPreviewCodeTabs($kitId, $code, $output, $options['height'] ?? '150px'); + } + + return $output; + } + + public static function highlightCode(string $language, string $code, string $style = 'margin-bottom: 1rem'): 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..c56c1a64f56 --- /dev/null +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -0,0 +1,143 @@ + + * + * 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\ToolkitKitId; +use App\Service\CommonMark\Extension\CodeBlockRenderer\CodeBlockRenderer; +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; + +class ToolkitService +{ + public function __construct( + #[Autowire(service: 'ux_toolkit.registry.registry_factory')] + private readonly RegistryFactory $registryFactory, + private readonly UriSigner $uriSigner, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function getKit(ToolkitKitId $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 (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); + } + + 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, + ]); + } + + 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 following file(s) 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 .= CodeBlockRenderer::highlightCode('shell', '$ composer require '.implode(' ', $phpPackageDependencies), 'margin-bottom: 0'); + $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', + 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, + ]); + } + + /** + * @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; + } +} diff --git a/ux.symfony.com/src/Service/UxPackageRepository.php b/ux.symfony.com/src/Service/UxPackageRepository.php index 0810e0e8def..d488ea98532 100644 --- a/ux.symfony.com/src/Service/UxPackageRepository.php +++ b/ux.symfony.com/src/Service/UxPackageRepository.php @@ -231,6 +231,20 @@ public function findAll(?string $query = null): array 'Switch the visibility of a password field', ), + new UxPackage( + 'toolkit', + 'Toolkit', + 'app_toolkit', + '#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, + null, + null, + true + ), + (new UxPackage( 'typed', 'Typed', 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..6bfad3eed84 --- /dev/null +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -0,0 +1,83 @@ + + * + * 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\ToolkitKitId; +use App\Service\Toolkit\ToolkitService; +use Symfony\Component\String\AbstractString; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +use function Symfony\Component\String\s; + +#[AsTwigComponent] +class ComponentDoc +{ + public ToolkitKitId $kitId; + public Component $component; + + public function __construct(private readonly ToolkitService $toolkitService) + { + } + + 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 + { + return $markdownContent->replace( + '', + $this->toolkitService->renderInstallationSteps($this->kitId, $this->component) + ); + } + + private function insertUsage(AbstractString $markdownContent): AbstractString + { + $firstTwigPreviewBlock = $markdownContent->match('/```twig.*?\n(.+?)```/s'); + $firstTwigPreviewBlock = $firstTwigPreviewBlock ? trim($firstTwigPreviewBlock[1]) : ''; + + return $markdownContent->replace( + '', + '```twig'."\n".$firstTwigPreviewBlock."\n".'```' + ); + } + + /** + * 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) { + $lang = $matches['lang']; + $options = json_decode($matches['options'], true, flags: \JSON_THROW_ON_ERROR); + + if ($options['preview'] ?? false) { + $options['kit'] = $this->kitId->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 98914f57672..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,27 @@ "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": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "0.2", + "ref": "7243ab070ed66198eb82c026684e9b9773e7b64a" + } + }, "theseer/tokenizer": { "version": "1.2.1" }, 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/_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/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/components/Button.html.twig b/ux.symfony.com/templates/components/Button.html.twig new file mode 100644 index 00000000000..ec285d78418 --- /dev/null +++ b/ux.symfony.com/templates/components/Button.html.twig @@ -0,0 +1,42 @@ +{%- props variant = 'default', outline = false, size = 'default' -%} +{%- set style = html_cva( + 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', + }, + outline: { + 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', + }, + }, + compoundVariants: [{ + variant: ['default'], + outline: ['true'], + class: 'border-primary', + }, { + variant: ['secondary'], + outline: ['true'], + class: 'border-secondary', + }, { + variant: ['destructive'], + outline: ['true'], + class: 'border-destructive', + }] +) -%} + + diff --git a/ux.symfony.com/templates/components/Card.html.twig b/ux.symfony.com/templates/components/Card.html.twig index 72474c38bdb..332bd4c14ef 100644 --- a/ux.symfony.com/templates/components/Card.html.twig +++ b/ux.symfony.com/templates/components/Card.html.twig @@ -8,9 +8,9 @@ width="640" height="360" alt="{{ name }}" - {% if lazyload %} - loading="lazy" - {% endif %} + {% if lazyload %} + loading="lazy" + {% endif %} >
    diff --git a/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig b/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig new file mode 100644 index 00000000000..d2ba664ebb7 --- /dev/null +++ b/ux.symfony.com/templates/components/Code/CodeBlockEmbed.html.twig @@ -0,0 +1,34 @@ +{% props + code = '', + language = 'twig', + highlight = true, +%} + +
    + {% set _code -%} + {% block content -%} + {{ code ? code|trim|raw }} + {%- endblock -%} + {%- endset %} + + {% if highlight %} +
    {{ _code ? _code|trim|raw|highlight(language) : code|highlight(language) }}
    +        
    + {% else %} +
    {{ _code ? _code|trim|raw : code }}
    + {% endif %} + + +
    diff --git a/ux.symfony.com/templates/components/CookbookCard.html.twig b/ux.symfony.com/templates/components/CookbookCard.html.twig new file mode 100644 index 00000000000..72474c38bdb --- /dev/null +++ b/ux.symfony.com/templates/components/CookbookCard.html.twig @@ -0,0 +1,33 @@ +{% 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/DocsLink.html.twig b/ux.symfony.com/templates/components/DocsLink.html.twig index ad82aaa6511..234223a0e51 100644 --- a/ux.symfony.com/templates/components/DocsLink.html.twig +++ b/ux.symfony.com/templates/components/DocsLink.html.twig @@ -2,7 +2,7 @@