diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a17aa41e180..596b291f7ae 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -78,12 +78,20 @@ jobs: dependency-version: 'highest' - php-version: '8.4' dependency-version: 'highest' + - php-version: '8.2' + minimum-stability: stable + component: Toolkit + - php-version: '8.2' + minimum-stability: dev + component: Toolkit component: ${{ fromJson(needs.tests-php-components.outputs.components )}} exclude: - php-version: '8.1' minimum-stability: 'dev' - php-version: '8.3' minimum-stability: 'dev' + - component: Toolkit # does not support PHP 8.1 + php-version: '8.1' - component: Swup # has no tests - component: Turbo # has its own workflow (test-turbo.yml) - component: Typed # has no tests diff --git a/.github/workflows/toolkit-kits-cs.yaml b/.github/workflows/toolkit-kits-cs.yaml new file mode 100644 index 00000000000..fcd5e36f3c2 --- /dev/null +++ b/.github/workflows/toolkit-kits-cs.yaml @@ -0,0 +1,28 @@ +name: Toolkit Kits + +on: + push: + paths: + - 'src/Toolkit/kits/**' + pull_request: + paths: + - 'src/Toolkit/kits/**' + +jobs: + kits-cs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Install composer packages + uses: ramsey/composer-install@v3 + with: + working-directory: src/Toolkit + + - name: Check kits code style + run: php vendor/bin/twig-cs-fixer check kits + working-directory: src/Toolkit diff --git a/src/Toolkit/.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..9bdd89127c5 --- /dev/null +++ b/src/Toolkit/bin/ux-toolkit-kit-debug @@ -0,0 +1,61 @@ +#!/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\Dependency\DependenciesResolver; +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 DependenciesResolver($filesystem)); +$registryFactory = new RegistryFactory(new class([ + Type::Local->value => fn () => new LocalRegistry($kitFactory, $filesystem, getcwd() ?? throw new \RuntimeException('The current working directory could not be determined.')), + Type::GitHub->value => fn () => new GitHubRegistry($kitFactory, $filesystem, class_exists(HttpClient::class) ? HttpClient::create() : null), +]) implements ServiceProviderInterface { + use ServiceLocatorTrait; +}); + +(new Application())->add($command = new DebugKitCommand($registryFactory)) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/src/Toolkit/bin/ux-toolkit-kit-lint b/src/Toolkit/bin/ux-toolkit-kit-lint new file mode 100755 index 00000000000..fee8015a660 --- /dev/null +++ b/src/Toolkit/bin/ux-toolkit-kit-lint @@ -0,0 +1,61 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if ('cli' !== \PHP_SAPI) { + throw new Exception('This script must be run from the command line.'); +} + +use Symfony\Component\Console\Application; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\Service\ServiceLocatorTrait; +use Symfony\Contracts\Service\ServiceProviderInterface; +use Symfony\UX\Toolkit\Command\LintKitCommand; +use Symfony\UX\Toolkit\Dependency\DependenciesResolver; +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 DependenciesResolver($filesystem)); +$registryFactory = new RegistryFactory(new class([ + Type::Local->value => fn () => new LocalRegistry($kitFactory, $filesystem, getcwd() ?? throw new \RuntimeException('The current working directory could not be determined.')), + Type::GitHub->value => fn () => new GitHubRegistry($kitFactory, $filesystem, class_exists(HttpClient::class) ? HttpClient::create() : null), +]) implements ServiceProviderInterface { + use ServiceLocatorTrait; +}); + +(new Application())->add($command = new LintKitCommand($registryFactory)) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json new file mode 100644 index 00000000000..e767161609d --- /dev/null +++ b/src/Toolkit/composer.json @@ -0,0 +1,75 @@ +{ + "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": "^6.4|^7.0", + "vincentlanglet/twig-cs-fixer": "^3.5" + }, + "bin": [ + "bin/ux-toolkit-kit-create", + "bin/ux-toolkit-kit-lint", + "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..b2b3074e5ae --- /dev/null +++ b/src/Toolkit/config/services.php @@ -0,0 +1,113 @@ + + * + * 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\Command\InstallKitCommand; +use Symfony\UX\Toolkit\Command\LintKitCommand; +use Symfony\UX\Toolkit\Component\ComponentInstaller; +use Symfony\UX\Toolkit\Dependency\DependenciesResolver; +use Symfony\UX\Toolkit\Kit\KitFactory; +use Symfony\UX\Toolkit\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.registry.factory'), + ]) + ->tag('console.command') + + ->set('.ux_toolkit.command.install', InstallComponentCommand::class) + ->args([ + param('ux_toolkit.kit'), + service('.ux_toolkit.registry.factory'), + service('.ux_toolkit.component.component_installer'), + ]) + ->tag('console.command') + + ->set('.ux_toolkit.command.install_kit', InstallKitCommand::class) + ->args([ + param('ux_toolkit.kit'), + service('.ux_toolkit.registry.factory'), + service('.ux_toolkit.component.component_installer'), + ]) + ->tag('console.command') + + ->set('.ux_toolkit.command.lint_kit', LintKitCommand::class) + ->args([ + service('.ux_toolkit.registry.factory'), + ]) + ->tag('console.command') + + // Registry + + ->set('.ux_toolkit.registry.factory', RegistryFactory::class) + ->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'), + param('kernel.project_dir'), + ]) + + ->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.factory', KitFactory::class) + ->args([ + service('filesystem'), + service('.ux_toolkit.dependency.dependencies_resolver'), + ]) + + ->set('.ux_toolkit.kit.kit_factory', KitFactory::class) + ->args([ + service('filesystem'), + service('.ux_toolkit.dependency.dependencies_resolver'), + ]) + + // Component + ->set('.ux_toolkit.component.component_installer', ComponentInstaller::class) + ->args([ + service('filesystem'), + ]) + + // Dependency + + ->set('.ux_toolkit.dependency.dependencies_resolver', DependenciesResolver::class) + ->args([ + service('filesystem'), + ]) + + ; +}; diff --git a/src/Toolkit/doc/index.rst b/src/Toolkit/doc/index.rst new file mode 100644 index 00000000000..b2d0aad554d --- /dev/null +++ b/src/Toolkit/doc/index.rst @@ -0,0 +1,158 @@ +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 the `UX Components page`_, 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 + +Configuration +------------- + +Configuration is done in your ``config/packages/ux_toolkit.yaml`` file: + +.. code-block:: yaml + + # config/packages/ux_toolkit.yaml + ux_toolkit: + kit: 'shadcn' + +Usage +----- + +You may find a list of components in the `UX Components page`_, with the installation instructions for each of them. + +For example, if you want to install the `Button` component, you will find the following instruction: + +.. code-block:: terminal + + $ php bin/console ux:toolkit:install-component Button + +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. + +Use your kit in a Symfony application +------------------------------------- + +You can globally configure the kit to use in your application by setting the ``ux_toolkit.kit`` configuration: + +.. code-block:: yaml + + # config/packages/ux_toolkit.yaml + ux_toolkit: + kit: 'github.com/my-username/my-ux-kits' + # or for a specific version + kit: 'github.com/my-username/my-ux-kits:1.0.0' + +If you do not want to globally configure the kit, you can pass the ``--kit`` option to the ``ux:toolkit:install-component`` command: + +.. code-block:: terminal + + $ php bin/console ux:toolkit:install-component Button --kit=github.com/my-username/my-ux-kits + +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 Components page`: https://ux.symfony.com/components +.. _`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..14de9f32083 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Alert.md @@ -0,0 +1,47 @@ +# Alert + +Displays a callout for user attention. + +```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/AlertDialog.md b/src/Toolkit/kits/shadcn/docs/components/AlertDialog.md new file mode 100644 index 00000000000..3109e0503ef --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/AlertDialog.md @@ -0,0 +1,80 @@ +# AlertDialog + +A modal dialog that interrupts the user with important content and expects a response. + +```twig {"preview":true} + + + Show Dialog + + + + Are you sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel + Continue + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + Show Dialog + + + + Are you sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel + Continue + + + +``` + +### Destructive + +```twig {"preview":true} + + + Delete Account + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel + Delete Account + + + +``` 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..43128d8604b --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md @@ -0,0 +1,47 @@ +# AspectRatio + +The AspectRatio component is a component that allows you to display an element with a specific aspect ratio. + +```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..08b085b3533 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Avatar.md @@ -0,0 +1,56 @@ +# Avatar + +A component for displaying user profile images with a fallback for when the image is not available. + +```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..492401d192b --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Badge.md @@ -0,0 +1,56 @@ +# Badge + +A component for displaying short pieces of information, such as counts, labels, or status indicators. + +```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..7b720e9af7f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md @@ -0,0 +1,89 @@ +# Breadcrumb + +A navigation component that displays the current page's location within a website's hierarchy. + +```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..82a7c655824 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Button.md @@ -0,0 +1,87 @@ +# Button + +A button component that can be used to trigger actions or events. + +```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..3c6bd140da6 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Card.md @@ -0,0 +1,84 @@ +# Card + +A container component for displaying content in a clear, structured format 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..8a01c3c4ead --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Input.md @@ -0,0 +1,56 @@ +# Input + +A form input component for text, email, password, and other input types. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### File + +```twig {"preview":true} +
+ + +
+``` + +### Disabled + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
+ Email + +
+``` + +### With Button + +```twig {"preview":true} +
+ + Subscribe +
+``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Label.md b/src/Toolkit/kits/shadcn/docs/components/Label.md new file mode 100644 index 00000000000..5280ab98666 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Label.md @@ -0,0 +1,47 @@ +# Label + +A component for labeling form elements 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..71135b10f00 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Pagination.md @@ -0,0 +1,101 @@ +# Pagination + +A component for navigating through paginated content with page numbers and navigation controls. + +```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..aa769256a6c --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Progress.md @@ -0,0 +1,47 @@ +# Progress + +A component for displaying progress 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..7fe10102b93 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Select.md @@ -0,0 +1,54 @@ +# Select + +A form component for selecting an option from a dropdown list. + +```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..3117d58b272 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Separator.md @@ -0,0 +1,65 @@ +# Separator + +A component for creating visual separators between content. + +```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..b01bf5f940c --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md @@ -0,0 +1,47 @@ +# Skeleton + +A component for displaying a loading state with a placeholder animation. + +```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..e143138ae94 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Switch.md @@ -0,0 +1,53 @@ +# Switch + +A toggle switch component for boolean input. + +```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..24c8b16cd34 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Table.md @@ -0,0 +1,83 @@ +# Table + +A component for displaying structured data in rows and columns with support for headers, captions, and customizable styling. + +```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..bf6a752d439 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Textarea.md @@ -0,0 +1,38 @@ +# Textarea + +A multi-line text input component for longer text content. + +```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..6a39b488b8d --- /dev/null +++ b/src/Toolkit/kits/shadcn/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Shadcn UI", + "description": "Component based on the Shadcn UI library, one of the most popular design systems in JavaScript world.", + "license": "MIT", + "homepage": "https://ux.symfony.com/components", + "authors": ["Shadcn", "Symfony Community"], + "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..36ae0d2a9ed --- /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..159efda191c --- /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..d2585502353 --- /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..93c3d1a4a5d --- /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..3ce5a9ae4cb --- /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..2863553f8df --- /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..553e1de92af --- /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..a5a9386658d --- /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..02152b1baa2 --- /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..02f1ac29c9e --- /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..0f352f93357 --- /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..7faad650f4e --- /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..4336acb6253 --- /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..cbfc3f5e6e6 --- /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..ca3065ff762 --- /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.defaults({}).without('class') }} +> + {%- 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..badee926be3 --- /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..bfa3277b3d7 --- /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..445a6c69922 --- /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..055fd3e6f2c --- /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..53c65ea4ae0 --- /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..d1b5ebd105d --- /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..7c9208ed188 --- /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..5c0dd260ebc --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Input.html.twig @@ -0,0 +1,6 @@ +{%- props type = 'text' -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Label.html.twig b/src/Toolkit/kits/shadcn/templates/components/Label.html.twig new file mode 100644 index 00000000000..661d333d0e3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Label.html.twig @@ -0,0 +1,6 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig new file mode 100644 index 00000000000..3eaf0911b78 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig @@ -0,0 +1,7 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig new file mode 100644 index 00000000000..9034e2a9b72 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig @@ -0,0 +1,5 @@ +
      + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig new file mode 100644 index 00000000000..b79f11d0fac --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig @@ -0,0 +1,8 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig new file mode 100644 index 00000000000..1029344f0ea --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig @@ -0,0 +1,3 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig new file mode 100644 index 00000000000..86c8adc46fe --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig @@ -0,0 +1,10 @@ +{%- props isActive = false, size = 'icon' -%} + + {{- block(outerBlocks.content) -}} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig new file mode 100644 index 00000000000..cd09ce5b044 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig @@ -0,0 +1,9 @@ + + Next + + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig new file mode 100644 index 00000000000..1d09bd7739c --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig @@ -0,0 +1,9 @@ + + + Previous + diff --git a/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig b/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig new file mode 100644 index 00000000000..2d62f6c7e82 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig @@ -0,0 +1,12 @@ +{%- props value = 0 -%} + +
    +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Select.html.twig b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig new file mode 100644 index 00000000000..aad4affc10b --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig @@ -0,0 +1,6 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig new file mode 100644 index 00000000000..b85e39e0408 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig @@ -0,0 +1,18 @@ +{%- props orientation = 'horizontal', decorative = true -%} +{%- set style = html_cva( + base: 'shrink-0 bg-border', + variants: { + orientation: { + horizontal: 'h-[1px] w-full', + vertical: 'h-full w-[1px]', + }, + }, +) -%} +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig new file mode 100644 index 00000000000..0e1301616a7 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig @@ -0,0 +1,4 @@ +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig new file mode 100644 index 00000000000..dac0998fb52 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig @@ -0,0 +1,4 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig new file mode 100644 index 00000000000..48c55aa2d93 --- /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..f7efc6bc957 --- /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..aa41d4be8ba --- /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..9a01544a0e9 --- /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..07503fbe3aa --- /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..33273f96ea6 --- /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..610756b8c59 --- /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..4e01c5c8850 --- /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..399593a0e8a --- /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..0b5ea05456f --- /dev/null +++ b/src/Toolkit/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + tests + + + + + + + + src + + + diff --git a/src/Toolkit/src/Assert.php b/src/Toolkit/src/Assert.php new file mode 100644 index 00000000000..b841ed3242c --- /dev/null +++ b/src/Toolkit/src/Assert.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit; + +final readonly 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 package name "%s".', $name)); + } + } +} diff --git a/src/Toolkit/src/Command/CreateKitCommand.php b/src/Toolkit/src/Command/CreateKitCommand.php new file mode 100644 index 00000000000..ddbe6ff2920 --- /dev/null +++ b/src/Toolkit/src/Command/CreateKitCommand.php @@ -0,0 +1,145 @@ + + * + * 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 is 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 is the homepage of your kit?'); + $question->setValidator(function (?string $value) { + if (empty($value) || !filter_var($value, \FILTER_VALIDATE_URL)) { + throw new \Exception('The homepage must be a valid URL'); + } + + return $value; + }); + $kitHomepage = $io->askQuestion($question); + + // Get the kit author name + $question = new Question('What is the author name of your kit?'); + $question->setValidator(function (?string $value) { + if (empty($value)) { + throw new \Exception('The author name cannot be empty'); + } + + return $value; + }); + $kitAuthorName = $io->askQuestion($question); + + // Get the kit license + $question = new Question('What is the license of your kit?'); + $question->setValidator(function (string $value) { + 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, + 'authors' => [$kitAuthorName], + '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.twig', << + Click me + +``` + +# Button with Variants + +```twig +Default +Secondary +``` +{% endblock %} +TWIG + ); + + $io->success('Perfect, you can now start building your kit!'); + + 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..1aece4c5807 --- /dev/null +++ b/src/Toolkit/src/Command/DebugKitCommand.php @@ -0,0 +1,103 @@ + + * + * 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\UX\Toolkit\Registry\RegistryFactory; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:debug-kit', + description: 'Debug a kit, dump the dependencies.', + hidden: true, +)] +class DebugKitCommand extends Command +{ + public function __construct( + private readonly RegistryFactory $registryFactory, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('kit', InputArgument::REQUIRED, 'The kit name, can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name").') + ->setHelp(<<<'EOF' +The kit name can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name"). + +To debug a local kit: + + php %command.full_name% shadcn + +To debug a GitHub kit: + + php %command.full_name% https://github.com/user/repository@kit-name + php %command.full_name% https://github.com/user/repository@kit-name@v1.0.0 + +EOF + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitName = $input->getArgument('kit'); + $registry = $this->registryFactory->getForKit($kitName); + $kit = $registry->getKit($kitName); + + $io->title(\sprintf('Kit "%s"', $kit->name)); + + $io->definitionList( + ['Name' => $kit->name], + ['Homepage' => $kit->homepage], + ['Authors' => implode(', ', $kit->authors)], + ['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..ba1cc1df523 --- /dev/null +++ b/src/Toolkit/src/Command/InstallComponentCommand.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Command; + +use Composer\InstalledVersions; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\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\Path; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Component\ComponentInstaller; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:install-component', + description: 'This command will install a new UX Component in your project', +)] +class InstallComponentCommand extends Command +{ + private SymfonyStyle $io; + private bool $isInteractive; + + public function __construct( + private readonly string $kitName, + private readonly RegistryFactory $registryFactory, + private readonly ComponentInstaller $componentInstaller, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('component', InputArgument::REQUIRED, 'The component name (Ex: Button)') + ->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') + ->addOption('kit', 'k', InputOption::VALUE_OPTIONAL, 'Override the kit name', $this->kitName) + ->setHelp( + <<%command.name% command will install a new UX Component in your project. + +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/repository@kit +php %command.full_name% Button --kit=https://github.com/user/repository@kit:branch +EOF + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + $this->isInteractive = $input->isInteractive(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitName = $input->getOption('kit'); + $registry = $this->registryFactory->getForKit($kitName); + $kit = $registry->getKit($kitName); + + // Get the component name from the argument, or suggest alternatives if it doesn't exist + if (null === $component = $kit->getComponent($componentName = $input->getArgument('component'))) { + $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; + } + } + + // Install the component and dependencies + $destination = $input->getOption('destination'); + + if (!$this->installComponent($kit, $component, $destination)) { + return Command::FAILURE; + } + + // Iterate over the component's dependencies + $phpDependenciesToInstall = []; + foreach ($component->getDependencies() as $dependency) { + if ($dependency instanceof ComponentDependency) { + if (!$this->installComponent($kit, $kit->getComponent($dependency->name), $destination)) { + return Command::FAILURE; + } + } elseif ($dependency instanceof PhpPackageDependency && !InstalledVersions::isInstalled($dependency->name)) { + $phpDependenciesToInstall[] = $dependency; + } + } + + $this->io->success(\sprintf('The component "%s" has been installed.', $component->name)); + + if ([] !== $phpDependenciesToInstall) { + $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $phpDependenciesToInstall))); + $this->io->newLine(); + } + + return Command::SUCCESS; + } + + /** + * Get alternative components that are similar to the given component name. + */ + 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; + } + + private function installComponent(Kit $kit, Component $component, string $destination, bool $force = false): bool + { + try { + $this->io->text(\sprintf('Installing component "%s"...', $component->name)); + + $this->componentInstaller->install($kit, $component, $destination); + } catch (ComponentAlreadyExistsException) { + if ($force) { + $this->componentInstaller->install($kit, $component, $destination, true); + + return true; + } + + $this->io->warning(\sprintf('The component "%s" already exists.', $component->name)); + + if ($this->isInteractive) { + if ($this->io->confirm('Do you want to overwrite it?')) { + $this->componentInstaller->install($kit, $component, $destination, true); + + return true; + } + } else { + return false; + } + } + + return true; + } +} diff --git a/src/Toolkit/src/Command/InstallKitCommand.php b/src/Toolkit/src/Command/InstallKitCommand.php new file mode 100644 index 00000000000..9a59f81f200 --- /dev/null +++ b/src/Toolkit/src/Command/InstallKitCommand.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Command; + +use Composer\InstalledVersions; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Component\ComponentInstaller; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +/** + * @author Jean-François Lépine + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:install-kit', + description: 'This command will install a full UX Toolkit kit in your project', +)] +class InstallKitCommand extends Command +{ + private SymfonyStyle $io; + private bool $isInteractive; + + public function __construct( + private readonly string $kitName, + private readonly RegistryFactory $registryFactory, + private readonly ComponentInstaller $componentInstaller, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the kit installation, even if some files already exist') + ->addOption('kit', 't', InputOption::VALUE_OPTIONAL, 'Override the kit name', $this->kitName) + ->setHelp( + <<%command.name% command will install a full UX Toolkit kit in your project. + +To fully install your current kit, use: + +php %command.full_name% + +To fully install a kit from an official UX Toolkit kit, use the --kit option: + +php %command.full_name% --kit=shadcn + +To fully install a kit from an external GitHub kit, use the --kit option: + +php %command.full_name% --kit=github.com/user/repository@kit +php %command.full_name% --kit=github.com/user/repository@kit:branch +EOF + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + $this->isInteractive = $input->isInteractive(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitName = $input->getOption('kit'); + $registry = $this->registryFactory->getForKit($kitName); + $kit = $registry->getKit($kitName); + + foreach ($kit->getComponents() as $component) { + if (!$this->installComponent($kit, $component, 'templates/components')) { + return Command::FAILURE; + } + } + + // Iterate over the component's dependencies + $phpDependenciesToInstall = []; + foreach ($component->getDependencies() as $dependency) { + if ($dependency instanceof ComponentDependency) { + if (!$this->installComponent($kit, $kit->getComponent($dependency->name), 'templates/components')) { + return Command::FAILURE; + } + } elseif ($dependency instanceof PhpPackageDependency && !\array_key_exists($dependency->name, $phpDependenciesToInstall) && !InstalledVersions::isInstalled($dependency->name)) { + $phpDependenciesToInstall[$dependency->name] = $dependency; + } + } + + if ([] !== $phpDependenciesToInstall) { + $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $phpDependenciesToInstall))); + $this->io->newLine(); + } + + return Command::SUCCESS; + } + + private function installComponent(Kit $kit, Component $component, string $destination, bool $force = false): bool + { + try { + $this->io->text(\sprintf('Installing component "%s"...', $component->name)); + + $this->componentInstaller->install($kit, $component, $destination); + } catch (ComponentAlreadyExistsException) { + if ($force) { + $this->componentInstaller->install($kit, $component, $destination, true); + + return true; + } + + $this->io->warning(\sprintf('The component "%s" already exists.', $component->name)); + + if ($this->isInteractive) { + if ($this->io->confirm('Do you want to overwrite it?')) { + $this->componentInstaller->install($kit, $component, $destination, true); + + return true; + } + } else { + return false; + } + } + + return true; + } +} diff --git a/src/Toolkit/src/Command/LintKitCommand.php b/src/Toolkit/src/Command/LintKitCommand.php new file mode 100644 index 00000000000..65300d38d64 --- /dev/null +++ b/src/Toolkit/src/Command/LintKitCommand.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\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:lint-kit', + description: 'Lint a kit, check for common mistakes and ensure the kit is valid.', + hidden: true, +)] +class LintKitCommand extends Command +{ + public function __construct( + private readonly RegistryFactory $registryFactory, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('kit', InputArgument::REQUIRED, 'The kit name, can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name").') + ->setHelp(<<<'EOF' +The kit name can be a local kit (e.g.: "shadcn") or a GitHub kit (e.g.: "https://github.com/user/repository@kit-name"). + +To lint a local kit: + +php %command.full_name% shadcn + +To lint a GitHub kit: + +php %command.full_name% https://github.com/user/repository@kit-name +php %command.full_name% https://github.com/user/repository@kit-name@v1.0.0 + +EOF + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitName = $input->getArgument('kit'); + $registry = $this->registryFactory->getForKit($kitName); + $kit = $registry->getKit($kitName); + + $io->success(\sprintf('The kit "%s" is valid, it has %d components.', $kit->name, \count($kit->getComponents()))); + + return Command::SUCCESS; + } +} diff --git a/src/Toolkit/src/Component/Component.php b/src/Toolkit/src/Component/Component.php new file mode 100644 index 00000000000..6b14fec7547 --- /dev/null +++ b/src/Toolkit/src/Component/Component.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\Component; + +use Symfony\UX\Toolkit\Assert; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\Dependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\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(Dependency $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; + } + } + + $this->dependencies[] = $dependency; + } + + /** + * @return list + */ + public function getDependencies(): array + { + return $this->dependencies; + } +} diff --git a/src/Toolkit/src/Component/ComponentInstaller.php b/src/Toolkit/src/Component/ComponentInstaller.php new file mode 100644 index 00000000000..1b702bd1ce8 --- /dev/null +++ b/src/Toolkit/src/Component/ComponentInstaller.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\Component; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; +use Symfony\UX\Toolkit\Kit\Kit; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final readonly class ComponentInstaller +{ + public function __construct( + private Filesystem $filesystem, + ) { + } + + /** + * @param non-empty-string $destination + */ + public function install(Kit $kit, Component $component, string $destination, bool $force = false): void + { + foreach ($component->files as $file) { + $componentPath = Path::join($kit->path, $file->relativePathNameToKit); + $componentDestinationPath = Path::join($destination, $file->relativePathName); + + if ($this->filesystem->exists($componentDestinationPath) && !$force) { + throw new ComponentAlreadyExistsException($component->name); + } + + $this->filesystem->copy($componentPath, $componentDestinationPath, $force); + } + } +} diff --git a/src/Toolkit/src/Dependency/ComponentDependency.php b/src/Toolkit/src/Dependency/ComponentDependency.php new file mode 100644 index 00000000000..9e42df415f1 --- /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 readonly class ComponentDependency implements Dependency +{ + /** + * @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/DependenciesResolver.php b/src/Toolkit/src/Dependency/DependenciesResolver.php new file mode 100644 index 00000000000..f7a2fa65068 --- /dev/null +++ b/src/Toolkit/src/Dependency/DependenciesResolver.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\File\FileType; +use Symfony\UX\Toolkit\Kit\Kit; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final readonly class DependenciesResolver +{ + /** + * @see https://regex101.com/r/WasRGf/1 + */ + private const RE_TWIG_COMPONENT_REFERENCES = '/[a-zA-Z0-9:_-]+)/'; + + public function __construct( + private Filesystem $filesystem, + ) { + } + + public function resolveDependencies(Kit $kit): void + { + foreach ($kit->getComponents() as $component) { + $this->resolveComponentDependencies($kit, $component); + } + } + + private function resolveComponentDependencies(Kit $kit, Component $component): void + { + // Find dependencies based on component name + foreach ($kit->getComponents() as $otherComponent) { + if ($component->name === $otherComponent->name) { + continue; + } + + // Find components with the component name as a prefix + if (str_starts_with($otherComponent->name, $component->name.':')) { + $component->addDependency(new ComponentDependency($otherComponent->name)); + } + } + + // Find dependencies based on file content + foreach ($component->files as $file) { + $fileContent = $this->filesystem->readFile(Path::join($kit->path, $file->relativePathNameToKit)); + + if (FileType::Twig === $file->type) { + if (str_contains($fileContent, 'html_cva')) { + $component->addDependency(new PhpPackageDependency('twig/extra-bundle')); + $component->addDependency(new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0))); + } + + if (str_contains($fileContent, 'tailwind_merge')) { + $component->addDependency(new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra')); + } + + if (str_contains($fileContent, 'name) { + continue; + } + + if ('ux:icon' === strtolower($componentReferenceName)) { + $component->addDependency(new PhpPackageDependency('symfony/ux-icons')); + } elseif ('ux:map' === strtolower($componentReferenceName)) { + $component->addDependency(new PhpPackageDependency('symfony/ux-map')); + } elseif (null === $componentReference = $kit->getComponent($componentReferenceName)) { + throw new \RuntimeException(\sprintf('Component "%s" not found in component "%s" (file "%s")', $componentReferenceName, $component->name, $file->relativePathNameToKit)); + } else { + $component->addDependency(new ComponentDependency($componentReference->name)); + } + } + } + } + } + } +} diff --git a/src/Toolkit/src/Dependency/Dependency.php b/src/Toolkit/src/Dependency/Dependency.php new file mode 100644 index 00000000000..29f2d9d907b --- /dev/null +++ b/src/Toolkit/src/Dependency/Dependency.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\Dependency; + +/** + * Represents a dependency. + * + * @internal + * + * @author Hugo Alliaume + */ +interface Dependency +{ + public function __toString(): string; +} diff --git a/src/Toolkit/src/Dependency/PhpPackageDependency.php b/src/Toolkit/src/Dependency/PhpPackageDependency.php new file mode 100644 index 00000000000..b6d813e3746 --- /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 readonly class PhpPackageDependency implements Dependency +{ + /** + * @param non-empty-string $name + */ + public function __construct( + public string $name, + public ?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/Version.php b/src/Toolkit/src/Dependency/Version.php new file mode 100644 index 00000000000..4bbd6417d68 --- /dev/null +++ b/src/Toolkit/src/Dependency/Version.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\Dependency; + +/** + * Represents a version number, following a simplified version of the SemVer specification. + * + * @internal + * + * @author Hugo Alliaume + */ +final readonly class Version +{ + /** + * @param int<0, max> $major + * @param int<0, max> $minor + * @param int<0, max> $patch + */ + public function __construct( + public int $major, + public int $minor, + public int $patch, + ) { + } + + public function isHigherThan(self $version): bool + { + return $this->major > $version->major + || ($this->major === $version->major && $this->minor > $version->minor) + || ($this->major === $version->major && $this->minor === $version->minor && $this->patch > $version->patch); + } + + public function __toString(): string + { + return \sprintf('%d.%d.%d', $this->major, $this->minor, $this->patch); + } +} diff --git a/src/Toolkit/src/Exception/ComponentAlreadyExistsException.php b/src/Toolkit/src/Exception/ComponentAlreadyExistsException.php new file mode 100644 index 00000000000..accf55f47d4 --- /dev/null +++ b/src/Toolkit/src/Exception/ComponentAlreadyExistsException.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\Exception; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class ComponentAlreadyExistsException extends \RuntimeException +{ + public function __construct( + public readonly string $componentName, + ) { + parent::__construct(\sprintf('The component "%s" already exists.', $this->componentName)); + } +} diff --git a/src/Toolkit/src/File/Doc.php b/src/Toolkit/src/File/Doc.php new file mode 100644 index 00000000000..8d2764366c0 --- /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 readonly class Doc +{ + /** + * @param non-empty-string $markdownContent + */ + public function __construct( + public string $markdownContent, + ) { + } +} diff --git a/src/Toolkit/src/File/File.php b/src/Toolkit/src/File/File.php new file mode 100644 index 00000000000..edb89ead5a7 --- /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 readonly class File +{ + /** + * @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 FileType $type, + public string $relativePathNameToKit, + public 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..fdebcc2565e --- /dev/null +++ b/src/Toolkit/src/File/FileType.php @@ -0,0 +1,29 @@ + + * + * 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'; + + public function getLabel(): string + { + return match ($this) { + self::Twig => 'Twig', + }; + } +} diff --git a/src/Toolkit/src/Kit/Kit.php b/src/Toolkit/src/Kit/Kit.php new file mode 100644 index 00000000000..5c668e1100d --- /dev/null +++ b/src/Toolkit/src/Kit/Kit.php @@ -0,0 +1,84 @@ + + * + * 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\Component\Component; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Kit +{ + /** + * @param non-empty-string $path + * @param non-empty-string $name + * @param non-empty-string $homepage + * @param list $authors + * @param non-empty-string $license + * @param list $components + */ + public function __construct( + public readonly string $path, + public readonly string $name, + public readonly string $homepage, + public readonly array $authors, + public readonly string $license, + public readonly ?string $description = null, + public readonly ?string $uxIcon = null, + public ?string $installAsMarkdown = null, + private array $components = [], + ) { + Assert::kitName($this->name); + + if (!Path::isAbsolute($this->path)) { + throw new \InvalidArgumentException(\sprintf('Kit path "%s" is not absolute.', $this->path)); + } + + if (!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; + } +} diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php new file mode 100644 index 00000000000..d8190e4a455 --- /dev/null +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -0,0 +1,128 @@ + + * + * 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\Component\Component; +use Symfony\UX\Toolkit\Dependency\DependenciesResolver; +use Symfony\UX\Toolkit\File\Doc; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final readonly class KitFactory +{ + public function __construct( + private Filesystem $filesystem, + private DependenciesResolver $dependencyResolver, + ) { + } + + /** + * @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($this->filesystem->readFile($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.'), + authors: $manifest['authors'] ?? throw new \InvalidArgumentException('Manifest file is missing "authors" key.'), + license: $manifest['license'] ?? throw new \InvalidArgumentException('Manifest file is missing "license" key.'), + description: $manifest['description'] ?? null, + uxIcon: $manifest['ux-icon'] ?? null, + ); + + $this->synchronizeKit($kit); + + return $kit; + } + + private function synchronizeKit(Kit $kit): void + { + $this->synchronizeKitComponents($kit); + $this->synchronizeKitDocumentation($kit); + } + + private function synchronizeKitComponents(Kit $kit): void + { + $componentsPath = Path::join('templates', 'components'); + $finder = (new Finder()) + ->in($kit->path) + ->files() + ->path($componentsPath) + ->sortByName() + ->name('*.html.twig') + ; + + foreach ($finder as $file) { + $relativePathNameToKit = $file->getRelativePathname(); + $relativePathName = str_replace($componentsPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); + $componentName = $this->extractComponentName($relativePathName); + $component = new Component( + name: $componentName, + files: [new File( + type: FileType::Twig, + relativePathNameToKit: $relativePathNameToKit, + relativePathName: $relativePathName, + )], + ); + + $kit->addComponent($component); + } + + $this->dependencyResolver->resolveDependencies($kit); + } + + private static function extractComponentName(string $pathnameRelativeToKit): string + { + return str_replace(['.html.twig', '/'], ['', ':'], $pathnameRelativeToKit); + } + + private function synchronizeKitDocumentation(Kit $kit): void + { + // Read INSTALL.md if exists + $fileInstall = Path::join($kit->path, 'INSTALL.md'); + if ($this->filesystem->exists($fileInstall)) { + $kit->installAsMarkdown = $this->filesystem->readFile($fileInstall); + } + + // Iterate over Component and find their documentation + foreach ($kit->getComponents() as $component) { + $docPath = Path::join($kit->path, 'docs', 'components', $component->name.'.md'); + if ($this->filesystem->exists($docPath)) { + $component->doc = new Doc($this->filesystem->readFile($docPath)); + } + } + } +} diff --git a/src/Toolkit/src/Registry/GitHubRegistry.php b/src/Toolkit/src/Registry/GitHubRegistry.php new file mode 100644 index 00000000000..9fb9f2b4b3a --- /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 Registry +{ + 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..cce44afd6f2 --- /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 readonly class GitHubRegistryIdentity +{ + /** + * @param non-empty-string $authorName + * @param non-empty-string $repositoryName + * @param non-empty-string $version + */ + private function __construct( + public string $authorName, + public string $repositoryName, + public 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..7295582de57 --- /dev/null +++ b/src/Toolkit/src/Registry/LocalRegistry.php @@ -0,0 +1,56 @@ + + * + * 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\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitFactory; + +/** + * @internal + * + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +final readonly class LocalRegistry implements Registry +{ + public static function supports(string $kitName): bool + { + return 1 === preg_match('/^[a-zA-Z0-9_-]+$/', $kitName); + } + + public function __construct( + private KitFactory $kitFactory, + private Filesystem $filesystem, + private string $projectDir, + ) { + } + + public function getKit(string $kitName): Kit + { + $possibleKitDirs = [ + // Local kit + Path::join($this->projectDir, 'kits', $kitName), + // From vendor + Path::join($this->projectDir, 'vendor', 'symfony', 'ux-toolkit', 'kits', $kitName), + ]; + + foreach ($possibleKitDirs as $kitDir) { + if ($this->filesystem->exists($kitDir)) { + return $this->kitFactory->createKitFromAbsolutePath($kitDir); + } + } + + throw new \RuntimeException(\sprintf('Unable to find the kit "%s" in the following directories: "%s"', $kitName, implode('", "', $possibleKitDirs))); + } +} diff --git a/src/Toolkit/src/Registry/Registry.php b/src/Toolkit/src/Registry/Registry.php new file mode 100644 index 00000000000..fb7e602c843 --- /dev/null +++ b/src/Toolkit/src/Registry/Registry.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 Registry +{ + 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/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php new file mode 100644 index 00000000000..29809a4bbc0 --- /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 readonly class RegistryFactory +{ + public function __construct( + private ContainerInterface $registries, + ) { + } + + /** + * @throws \InvalidArgumentException + */ + public function getForKit(string $kit): Registry + { + $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/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..317ee766081 --- /dev/null +++ b/src/Toolkit/src/UXToolkitBundle.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\Toolkit; + +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; + +/** + * @author Jean-François Lépine + * @author Hugo Alliaume + */ +class UXToolkitBundle extends AbstractBundle +{ + protected string $extensionAlias = 'ux_toolkit'; + + public function configure(DefinitionConfigurator $definition): void + { + $rootNode = $definition->rootNode(); + $rootNode + ->children() + ->scalarNode('kit') + ->info('The kit to use, it can be from the official UX Toolkit repository, or an external GitHub repository') + ->defaultValue('shadcn') + ->example([ + 'shadcn', + 'github.com/user/repository@my-kit', + 'github.com/user/repository@my-kit:main', + 'https://github.com/user/repository@my-kit', + ]) + ->end() + ->end(); + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->parameters() + ->set('ux_toolkit.kit', $config['kit']) + ; + + $container->import('../config/services.php'); + } +} diff --git a/src/Toolkit/tests/AssertTest.php b/src/Toolkit/tests/AssertTest.php new file mode 100644 index 00000000000..2c8cc114dd5 --- /dev/null +++ b/src/Toolkit/tests/AssertTest.php @@ -0,0 +1,179 @@ + + * + * 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 + { + Assert::componentName($name); + $this->addToAssertionCount(1); + } + + 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 package name "%s".', $name)); + + Assert::phpPackageName($name); + } + + public static function provideInvalidPhpPackageNames(): iterable + { + yield ['']; + yield ['twig']; + yield ['twig/html-extra/']; + yield ['twig/html-extra/twig']; + } +} diff --git a/src/Toolkit/tests/Command/DebugKitCommandTest.php b/src/Toolkit/tests/Command/DebugKitCommandTest.php new file mode 100644 index 00000000000..5933a32f9e9 --- /dev/null +++ b/src/Toolkit/tests/Command/DebugKitCommandTest.php @@ -0,0 +1,43 @@ + + * + * 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('ux:toolkit:debug-kit shadcn') + ->execute() + ->assertSuccessful() + // Kit details + ->assertOutputContains('Name Shadcn') + ->assertOutputContains('Homepage https://ux.symfony.com/components') + ->assertOutputContains('Authors Shadcn, Symfony Community') + ->assertOutputContains('License MIT') + // A component details + ->assertOutputContains(<<<'EOF' ++--------------+----------------------- 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..9ac82b58e90 --- /dev/null +++ b/src/Toolkit/tests/Command/InstallComponentCommandTest.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\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); + } + + $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) + ->execute() + ->assertSuccessful() + ->assertOutputContains('The component "Table" has been installed.') + ; + + // Files should be created, + foreach ($expectedFiles as $fileName => $expectedFile) { + $this->assertFileExists($expectedFile); + $this->assertEquals(file_get_contents(__DIR__.'/../../kits/shadcn/templates/components/'.$fileName), file_get_contents($expectedFile)); + } + } + + public function testShouldFailAndSuggestAlternativeComponents(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Table: --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 testShouldFailWhenComponentFileAlreadyExistsInNonInteractiveMode(): 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('The component "Badge" already exists.') + ; + } +} diff --git a/src/Toolkit/tests/Command/LintKitCommandTest.php b/src/Toolkit/tests/Command/LintKitCommandTest.php new file mode 100644 index 00000000000..cdc94705b58 --- /dev/null +++ b/src/Toolkit/tests/Command/LintKitCommandTest.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\Tests\Command; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Console\Test\InteractsWithConsole; + +class LintKitCommandTest extends KernelTestCase +{ + use InteractsWithConsole; + + public function testShouldBeAbleToLint(): void + { + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:lint-kit shadcn') + ->execute() + ->assertSuccessful() + ->assertOutputContains('The kit "Shadcn UI" is valid, it has 46 components') + ; + } +} diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php new file mode 100644 index 00000000000..c289a95cc66 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/Kernel.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 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\Toolkit\UXToolkitBundle; +use Symfony\UX\TwigComponent\TwigComponentBundle; + +final class Kernel extends BaseKernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new TwigBundle(), + new TwigComponentBundle(), + 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, + ]); + + $container->extension('twig', [ + 'default_path' => __DIR__.'/../../kits', + ]); + + $container->extension('twig_component', [ + 'anonymous_template_directory' => 'components/', + 'defaults' => [], + ]); + + $container->services() + ->alias('ux_toolkit.kit.factory', '.ux_toolkit.kit.factory') + ->public() + + ->alias('ux_toolkit.kit.dependencies_resolver', '.ux_toolkit.dependency.dependencies_resolver') + ->public() + + ->alias('ux_toolkit.registry.factory', '.ux_toolkit.registry.factory') + ->public() + ; + } +} diff --git a/src/Toolkit/tests/Kit/ComponentInstallerTest.php b/src/Toolkit/tests/Kit/ComponentInstallerTest.php new file mode 100644 index 00000000000..23ed184a80d --- /dev/null +++ b/src/Toolkit/tests/Kit/ComponentInstallerTest.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Component\ComponentInstaller; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Exception\ComponentAlreadyExistsException; +use Symfony\UX\Toolkit\Kit\Kit; + +final class ComponentInstallerTest 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 ComponentInstaller(self::getContainer()->get('filesystem')); + $kit = $this->createKit('shadcn'); + + $this->assertFileDoesNotExist($this->tmpDir.'/Button.html.twig'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->install($kit, $component, $this->tmpDir); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + } + + public function testShouldFailIfComponentAlreadyExists(): void + { + $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $kit = $this->createKit('shadcn'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->install($kit, $component, $this->tmpDir); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + + $this->expectException(ComponentAlreadyExistsException::class); + $this->expectExceptionMessage('The component "Button" already exists.'); + + $componentInstaller->install($kit, $component, $this->tmpDir); + } + + public function testCanInstallComponentIfForced(): void + { + $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $kit = $this->createKit('shadcn'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->install($kit, $component, $this->tmpDir); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + + // No exception should be thrown, the file should be overwritten + $componentInstaller->install($kit, $component, $this->tmpDir, true); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame($this->filesystem->readFile($this->tmpDir.'/Button.html.twig'), $this->filesystem->readFile(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + } + + public function testCanInstallComponentAndItsComponentDependencies(): void + { + $componentInstaller = new ComponentInstaller(self::getContainer()->get('filesystem')); + $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', + ]; + + foreach ($expectedFiles as $expectedFile) { + $this->assertFileDoesNotExist($expectedFile); + } + + $component = $kit->getComponent('Table'); + $this->assertNotNull($component); + + // Install the component and its dependencies + $componentInstaller->install($kit, $component, $this->tmpDir); + foreach ($component->getDependencies() as $dependency) { + if ($dependency instanceof ComponentDependency) { + $dependencyComponent = $kit->getComponent($dependency->name); + $this->assertNotNull($dependencyComponent); + + $componentInstaller->install($kit, $dependencyComponent, $this->tmpDir); + } + } + + foreach ($expectedFiles as $fileName => $expectedFile) { + $this->assertFileExists($expectedFile); + $this->assertSame($this->filesystem->readFile($expectedFile), $this->filesystem->readFile(\sprintf('%s/templates/components/%s', $kit->path, $fileName))); + } + } + + private function createKit(string $kitName): Kit + { + return self::getContainer()->get('ux_toolkit.kit.factory')->createKitFromAbsolutePath(Path::join(__DIR__, '../../kits', $kitName)); + } +} diff --git a/src/Toolkit/tests/Kit/ComponentTest.php b/src/Toolkit/tests/Kit/ComponentTest.php new file mode 100644 index 00000000000..f88c4fb9b46 --- /dev/null +++ b/src/Toolkit/tests/Kit/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\Kit; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Component\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/Kit/DependenciesResolverTest.php b/src/Toolkit/tests/Kit/DependenciesResolverTest.php new file mode 100644 index 00000000000..e9476f8a8b2 --- /dev/null +++ b/src/Toolkit/tests/Kit/DependenciesResolverTest.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\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\DependenciesResolver; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\File\FileType; +use Symfony\UX\Toolkit\Kit\Kit; + +final class DependenciesResolverTest extends KernelTestCase +{ + private Filesystem $filesystem; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + } + + public function testCanResolveDependencies(): void + { + $dependenciesResolver = new DependenciesResolver($this->filesystem); + + $kit = new Kit(Path::join(__DIR__, '../../kits/shadcn'), 'shadcn', 'https://shadcn.com', ['Shadcn'], 'MIT'); + $kit->addComponent($button = new Component('Button', [new File(FileType::Twig, 'templates/components/Button.html.twig', 'Button.html.twig')])); + $kit->addComponent($table = new Component('Table', [new File(FileType::Twig, 'templates/components/Table.html.twig', 'Table.html.twig')])); + $kit->addComponent(new Component('Table:Row', [new File(FileType::Twig, 'templates/components/Table/Row.html.twig', 'Table/Row.html.twig')])); + $kit->addComponent(new Component('Table:Cell', [new File(FileType::Twig, 'templates/components/Table/Cell.html.twig', 'Table/Cell.html.twig')])); + + $this->assertCount(0, $button->getDependencies()); + $this->assertCount(0, $table->getDependencies()); + + $dependenciesResolver->resolveDependencies($kit); + + $this->assertEquals([ + new PhpPackageDependency('twig/extra-bundle'), + new PhpPackageDependency('twig/html-extra', new Version(3, 12, 0)), + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + ], $button->getDependencies()); + + $this->assertEquals([ + new ComponentDependency('Table:Row'), + new ComponentDependency('Table:Cell'), + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + ], $table->getDependencies()); + } +} diff --git a/src/Toolkit/tests/Kit/Dependency/ComponentDependencyTest.php b/src/Toolkit/tests/Kit/Dependency/ComponentDependencyTest.php new file mode 100644 index 00000000000..dd434ae2bb1 --- /dev/null +++ b/src/Toolkit/tests/Kit/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\Kit\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/Kit/Dependency/PhpPackageDependencyTest.php b/src/Toolkit/tests/Kit/Dependency/PhpPackageDependencyTest.php new file mode 100644 index 00000000000..a6aa6c17aea --- /dev/null +++ b/src/Toolkit/tests/Kit/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\Kit\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 package name "/foo".'); + + new PhpPackageDependency('/foo'); + } +} diff --git a/src/Toolkit/tests/Kit/Dependency/VersionTest.php b/src/Toolkit/tests/Kit/Dependency/VersionTest.php new file mode 100644 index 00000000000..7f3e3f5dfbd --- /dev/null +++ b/src/Toolkit/tests/Kit/Dependency/VersionTest.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\Tests\Kit\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, $version->major); + $this->assertSame(2, $version->minor); + $this->assertSame(3, $version->patch); + $this->assertSame('1.2.3', (string) $version); + } + + public function testCanBeCompared(): void + { + $this->assertTrue((new Version(1, 2, 3))->isHigherThan(new Version(1, 2, 2))); + $this->assertFalse((new Version(1, 2, 3))->isHigherThan(new Version(1, 2, 4))); + $this->assertTrue((new Version(1, 2, 3))->isHigherThan(new Version(1, 1, 99))); + $this->assertFalse((new Version(1, 2, 3))->isHigherThan(new Version(1, 2, 3))); + $this->assertTrue((new Version(1, 2, 3))->isHigherThan(new Version(0, 99, 99))); + $this->assertFalse((new Version(1, 2, 3))->isHigherThan(new Version(2, 0, 0))); + } +} diff --git a/src/Toolkit/tests/Kit/DocTest.php b/src/Toolkit/tests/Kit/DocTest.php new file mode 100644 index 00000000000..e455c363577 --- /dev/null +++ b/src/Toolkit/tests/Kit/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\Kit; + +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/Kit/File/FileTest.php b/src/Toolkit/tests/Kit/File/FileTest.php new file mode 100644 index 00000000000..41e69c8dfc3 --- /dev/null +++ b/src/Toolkit/tests/Kit/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\Kit\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/Kit/KitFactoryTest.php b/src/Toolkit/tests/Kit/KitFactoryTest.php new file mode 100644 index 00000000000..b30269044ab --- /dev/null +++ b/src/Toolkit/tests/Kit/KitFactoryTest.php @@ -0,0 +1,77 @@ + + * + * 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\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +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 testCanCreateKit(): 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 component for displaying structured data in rows and columns with support for headers, captions, and customizable styling. +EOF + , $table->doc->markdownContent); + } + + private function createKitFactory(): KitFactory + { + return new KitFactory(self::getContainer()->get('filesystem'), self::getContainer()->get('ux_toolkit.kit.dependencies_resolver')); + } +} diff --git a/src/Toolkit/tests/Kit/KitTest.php b/src/Toolkit/tests/Kit/KitTest.php new file mode 100644 index 00000000000..11c1df98a7d --- /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\Component\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..f720e349523 --- /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.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.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..ca0bbd0fc76 --- /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.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..24f11395206 --- /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.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.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..9c000930e64 --- /dev/null +++ b/src/Toolkit/tests/UXToolkitBundleTest.php @@ -0,0 +1,27 @@ + + * + * 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']); + $this->assertEquals('shadcn', $container->getParameter('ux_toolkit.kit')); + } +} 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/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..dcedeeed626 100644 --- a/ux.symfony.com/assets/styles/app.scss +++ b/ux.symfony.com/assets/styles/app.scss @@ -75,7 +75,7 @@ $utilities: map-remove( // @import "../../vendor/twbs/bootstrap/scss/navbar"; @import "../../vendor/twbs/bootstrap/scss/card"; // @import "../../vendor/twbs/bootstrap/scss/accordion"; -// @import "../../vendor/twbs/bootstrap/scss/breadcrumb"; +@import "../../vendor/twbs/bootstrap/scss/breadcrumb"; // @import "../../vendor/twbs/bootstrap/scss/pagination"; // @import "../../vendor/twbs/bootstrap/scss/badge"; @import "../../vendor/twbs/bootstrap/scss/alert"; @@ -128,6 +128,7 @@ $utilities: map-remove( @import "components/Button"; @import "components/Browser"; @import "components/Changelog"; +@import "components/CodePreview_Tabs"; @import "components/DataList"; @import "components/DemoContainer"; @import "components/DemoCard"; @@ -144,6 +145,7 @@ $utilities: map-remove( @import "components/PackageHeader"; @import "components/PackageBox"; @import "components/PackageList"; +@import "components/SidebarNav"; @import "components/Cookbook"; @import "components/SupportBox"; @import "components/Tabs"; @@ -151,8 +153,10 @@ $utilities: map-remove( @import "components/Terminal"; @import "components/TerminalCommand"; @import "components/ThemeSwitcher"; +@import "components/Wysiwyg"; // Utilities +@import "utilities/animation"; @import "utilities/arrow"; @import "utilities/background"; @import "utilities/info-tooltips"; @@ -176,4 +180,3 @@ $utilities: map-remove( .code-description a { text-decoration: underline; } - diff --git a/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss b/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss new file mode 100644 index 00000000000..0398deaffa5 --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_CodePreview_Tabs.scss @@ -0,0 +1,60 @@ +.CodePreview_Tabs { +} + +.CodePreview_TabHead { + display: flex; + flex-direction: row; + margin-bottom: 1rem +} + +.CodePreview_TabControl { + border-bottom: 3px solid transparent; + color: var(--bs-primary-color); + padding: 0 1rem; + font-size: .9rem; + line-height: 2; + font-stretch: semi-condensed; + transition: border-color 150ms ease-in-out; + margin-bottom: -1px; +} + +.CodePreview_TabControl.active { + border-color: var(--bs-secondary-color); +} + +.CodePreview_TabPanel { + position: relative; +} + +.CodePreview_TabPanel:not(.active) { + display: none; +} + +.CodePreview_TabPanel:has(.CodePreview_Preview) { + border: 1px solid var(--bs-border-color); + border-radius: .75rem +} + +.CodePreview_Loader { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + gap: .2rem; + position: absolute; + + svg { + animation: rotating 1s linear infinite; + } +} + +.CodePreview_Preview { + width: 100%; + transition: opacity .250s linear; + border-radius: .75rem; + opacity: 1; + + &.loading { + opacity: 0; + } +} diff --git a/ux.symfony.com/assets/styles/components/_SidebarNav.scss b/ux.symfony.com/assets/styles/components/_SidebarNav.scss new file mode 100644 index 00000000000..7d890eea988 --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_SidebarNav.scss @@ -0,0 +1,22 @@ +.SidebarNav {} + +.SidebarNav_Heading { + font-weight: 600; + padding: 0 .5rem; +} + +.SidebarNav_Item { + border-radius: .5rem; + transition: background-color 100ms ease-in-out; +} +.SidebarNav_Item.active, +.SidebarNav_Item:hover { + background-color: var(--bs-secondary-bg); +} + + +.SidebarNav_Link { + display: block; + font-size: .9rem; + padding: .1rem .5rem; +} diff --git a/ux.symfony.com/assets/styles/components/_Wysiwyg.scss b/ux.symfony.com/assets/styles/components/_Wysiwyg.scss new file mode 100644 index 00000000000..739279b010d --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_Wysiwyg.scss @@ -0,0 +1,29 @@ +.Wysiwyg { + h1 { + font-family: var(--font-family-title); + font-size: 2.6rem; + font-weight: 700; + margin-bottom: 1rem; + } + + h2 { + font-family: var(--font-family-title); + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; + margin-top: 2rem; + } + + h3 { + font-family: var(--font-family-title); + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + margin-top: 1.5rem; + } + + a { + color: var(--bs-link-color); + text-decoration: underline; + } +} diff --git a/ux.symfony.com/assets/styles/toolkit-shadcn.css b/ux.symfony.com/assets/styles/toolkit-shadcn.css new file mode 100644 index 00000000000..a012610b7f3 --- /dev/null +++ b/ux.symfony.com/assets/styles/toolkit-shadcn.css @@ -0,0 +1,126 @@ +@import "tailwindcss"; +@source "../../vendor/symfony/ux-toolkit/kits"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + border-color: var(--border); + outline-color: var(--ring); + } + + body { + background-color: var(--background); + color: var(--foreground); + } +} diff --git a/ux.symfony.com/assets/styles/utilities/_animation.scss b/ux.symfony.com/assets/styles/utilities/_animation.scss new file mode 100644 index 00000000000..126431ca1c6 --- /dev/null +++ b/ux.symfony.com/assets/styles/utilities/_animation.scss @@ -0,0 +1,8 @@ +@keyframes rotating { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/ux.symfony.com/assets/styles/utilities/_shadow.scss b/ux.symfony.com/assets/styles/utilities/_shadow.scss index 775f59d3d7a..692fc38bf94 100644 --- a/ux.symfony.com/assets/styles/utilities/_shadow.scss +++ b/ux.symfony.com/assets/styles/utilities/_shadow.scss @@ -9,8 +9,13 @@ border-radius: 3rem; bottom: var(--shadow-bottom, 0); filter: blur(3rem); + opacity: var(--opacity, 1); } } .shadow-blur--rainbow { --gradient: linear-gradient(113.84deg, #D65831 0%, #D2D631 36.52%, #31D673 71.83%, #3aa3ff 100%) } + +.shadow-blur--opacity-20 { + --opacity: 0.2; +} diff --git a/ux.symfony.com/assets/toolkit-shadcn.js b/ux.symfony.com/assets/toolkit-shadcn.js new file mode 100644 index 00000000000..263378a5c3b --- /dev/null +++ b/ux.symfony.com/assets/toolkit-shadcn.js @@ -0,0 +1 @@ +import './styles/toolkit-shadcn.css'; diff --git a/ux.symfony.com/composer.json b/ux.symfony.com/composer.json index 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/packages/ux_toolkit.yaml b/ux.symfony.com/config/packages/ux_toolkit.yaml new file mode 100644 index 00000000000..92e23b6fd30 --- /dev/null +++ b/ux.symfony.com/config/packages/ux_toolkit.yaml @@ -0,0 +1,2 @@ +ux_toolkit: + kit: shadcn diff --git a/ux.symfony.com/config/services.yaml b/ux.symfony.com/config/services.yaml index 95f38b16b4c..38d4b5dd9a7 100644 --- a/ux.symfony.com/config/services.yaml +++ b/ux.symfony.com/config/services.yaml @@ -24,3 +24,6 @@ services: # please note that last definitions always *replace* previous ones Tempest\Highlight\Highlighter: tags: ['twig.runtime'] + + ux_toolkit.registry.factory: + alias: '.ux_toolkit.registry.factory' diff --git a/ux.symfony.com/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..bb70a547771 --- /dev/null +++ b/ux.symfony.com/src/Controller/Toolkit/ComponentsController.php @@ -0,0 +1,152 @@ + + * + * 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\File\FileType; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; +use Twig\Loader\ChainLoader; +use Twig\Loader\FilesystemLoader; + +class ComponentsController extends AbstractController +{ + public function __construct( + private ToolkitService $toolkitService, + private UxPackageRepository $uxPackageRepository, + ) { + } + + #[Route('/toolkit/kits/{kit}/components/')] + public function listComponents(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.twig_component.component_factory')] + ComponentFactory $componentFactory, + #[Autowire(service: 'profiler')] + ?Profiler $profiler, + ): Response { + if (!$uriSigner->checkRequest($request)) { + throw new BadRequestHttpException('Request is invalid.'); + } + + $profiler?->disable(); + + $kit = $this->toolkitService->getKit($kitId); + + $twig->setLoader(new ChainLoader([ + new FilesystemLoader($kit->path.\DIRECTORY_SEPARATOR.'templates'.\DIRECTORY_SEPARATOR.'components'), + $twig->getLoader(), + ])); + + $this->tweakComponentFactory( + $componentFactory, + new class($kit) implements ComponentTemplateFinderInterface { + public function __construct( + private readonly Kit $kit, + ) { + } + + public function findAnonymousComponentTemplate(string $name): ?string + { + if ($component = $this->kit->getComponent($name)) { + foreach ($component->files as $file) { + if (FileType::Twig === $file->type) { + return $file->relativePathName; + } + } + } + + return null; + } + } + ); + + $template = $twig->createTemplate(<< + + + Preview + + {{ importmap('toolkit-{$kitId->value}') }} + + {$code} + +HTML); + + return new Response( + $twig->render($template), + Response::HTTP_OK, + ['X-Robots-Tag' => 'noindex, nofollow'] + ); + } + + /** + * Tweak the ComponentFactory to render anonymous components from the Toolkit kit. + * TODO: In the future, we should implement multiple directories for anonymous components. + */ + private function tweakComponentFactory(ComponentFactory $componentFactory, ComponentTemplateFinderInterface $componentTemplateFinder): void + { + $refl = new \ReflectionClass($componentFactory); + + $propertyConfig = $refl->getProperty('config'); + $propertyConfig->setValue($componentFactory, []); + + $propertyComponentTemplateFinder = $refl->getProperty('componentTemplateFinder'); + $propertyComponentTemplateFinder->setValue($componentFactory, $componentTemplateFinder); + } +} diff --git a/ux.symfony.com/src/Controller/Toolkit/KitsController.php b/ux.symfony.com/src/Controller/Toolkit/KitsController.php new file mode 100644 index 00000000000..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..4da4c02d84a 100644 --- a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php +++ b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php @@ -11,12 +11,15 @@ namespace App\Service\CommonMark; +use App\Service\CommonMark\Extension\CodeBlockRenderer\CodeBlockRenderer; use League\CommonMark\CommonMarkConverter; +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Extension\ExternalLink\ExternalLinkExtension; use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\Mention\MentionExtension; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Tempest\Highlight\CommonMark\HighlightExtension; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * @author Kevin Bond @@ -24,6 +27,12 @@ #[AsDecorator('twig.markdown.league_common_mark_converter_factory')] final class ConverterFactory { + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly UriSigner $uriSigner, + ) { + } + public function __invoke(): CommonMarkConverter { $converter = new CommonMarkConverter([ @@ -47,8 +56,8 @@ public function __invoke(): CommonMarkConverter $converter->getEnvironment() ->addExtension(new ExternalLinkExtension()) ->addExtension(new MentionExtension()) - ->addExtension(new HighlightExtension()) ->addExtension(new FrontMatterExtension()) + ->addRenderer(FencedCode::class, new CodeBlockRenderer($this->urlGenerator, $this->uriSigner)) ; return $converter; diff --git a/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php new file mode 100644 index 00000000000..43174362f7b --- /dev/null +++ b/ux.symfony.com/src/Service/CommonMark/Extension/CodeBlockRenderer/CodeBlockRenderer.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service\CommonMark\Extension\CodeBlockRenderer; + +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Tempest\Highlight\Highlighter; +use Tempest\Highlight\WebTheme; + +final readonly class CodeBlockRenderer implements NodeRendererInterface +{ + public function __construct( + private UrlGeneratorInterface $urlGenerator, + private UriSigner $uriSigner, + ) { + } + + #[\Override] + public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable|string|null + { + if (!$node instanceof FencedCode) { + throw new \InvalidArgumentException('Block must be instance of '.FencedCode::class); + } + + $infoWords = $node->getInfoWords(); + $language = $infoWords[0] ?? 'txt'; + $options = isset($infoWords[1]) && json_validate($infoWords[1]) ? json_decode($infoWords[1], true) : []; + $preview = $options['preview'] ?? false; + $kit = $options['kit'] ?? null; + $height = $options['height'] ?? '150px'; + + $code = $node->getLiteral(); + + $output = $this->highlightCode($code, $language); + + if ($preview && $kit) { + $previewUrl = $this->uriSigner->sign($this->urlGenerator->generate('app_toolkit_component_preview', [ + 'kitId' => $kit, + 'code' => $code, + 'height' => $height, + ], UrlGeneratorInterface::ABSOLUTE_URL)); + + $output = << + +
    +
    +
    + + Loading... +
    + +
    +
    {$output}
    +
    + +HTML; + } + + return $output; + } + + private function highlightCode(string $code, string $language): string + { + $highlighter = new Highlighter(); + + $theme = $highlighter->getTheme(); + $parsed = $highlighter->parse($code, $language); + $output = $theme instanceof WebTheme + ? $theme->preBefore($highlighter).$parsed.$theme->preAfter($highlighter) + : '
    '.$parsed.'
    '; + + return << +
    +
    + {$output} +
    +
    + + HTML; + } +} diff --git a/ux.symfony.com/src/Service/Toolkit/ToolkitService.php b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php new file mode 100644 index 00000000000..52c0ccbc1e4 --- /dev/null +++ b/ux.symfony.com/src/Service/Toolkit/ToolkitService.php @@ -0,0 +1,57 @@ + + * + * 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 Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +class ToolkitService +{ + public function __construct( + #[Autowire(service: 'ux_toolkit.registry.factory')] + private RegistryFactory $registryFactory, + ) { + } + + 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); + } +} 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..92eef065e6a --- /dev/null +++ b/ux.symfony.com/src/Twig/Components/Toolkit/ComponentDoc.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\Components\Toolkit; + +use App\Enum\ToolkitKitId; +use App\Util\SourceCleaner; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\String\AbstractString; +use Symfony\UX\Toolkit\Component\Component; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Tempest\Highlight\Highlighter; + +use function Symfony\Component\String\s; + +#[AsTwigComponent] +class ComponentDoc +{ + public ToolkitKitId $kitId; + public Component $component; + + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly UriSigner $uriSigner, + private readonly Highlighter $highlighter, + private readonly \Twig\Environment $twig, + ) { + } + + public function getContent(): string + { + return $this->formatContent($this->component->doc->markdownContent); + } + + private function formatContent(string $markdownContent): string + { + $markdownContent = s($markdownContent); + + $markdownContent = $this->insertInstallation($markdownContent); + $markdownContent = $this->insertUsage($markdownContent); + $markdownContent = $this->adaptPreviewableCodeBlocks($markdownContent); + + return $markdownContent; + } + + private function insertInstallation(AbstractString $markdownContent): AbstractString + { + $installationCode = SourceCleaner::processTerminalLines(<<component->name} +# or if you already use another kit +symfony console ux:toolkit:install-component {$this->component->name} --kit {$this->kitId->value} +SHELL + ); + + return $markdownContent->replace( + '', + << +
    +
    +
    {$installationCode}
    +
    +
    + + HTML + ); + } + + private function insertUsage(AbstractString $markdownContent): AbstractString + { + $firstTwigPreviewBlock = $markdownContent->match('/```twig.*?\n(.+?)```/s'); + $firstTwigPreviewBlock = $firstTwigPreviewBlock ? trim($firstTwigPreviewBlock[1]) : ''; + + return $markdownContent->replace( + '', + '```twig'."\n".$firstTwigPreviewBlock."\n".'```' + ); + } + + private function adaptPreviewableCodeBlocks(AbstractString $markdownContent): AbstractString + { + return $markdownContent->replaceMatches('/```(?P[a-z]+) +(?P\{.+?\})\n/', function (array $matches) { + $lang = $matches['lang']; + $options = json_decode($matches['options'], true, flags: \JSON_THROW_ON_ERROR); + + if ($options['preview'] ?? false) { + $options['kit'] = $this->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/_header.html.twig b/ux.symfony.com/templates/_header.html.twig index c0ce67fb6c0..dc16629ea38 100644 --- a/ux.symfony.com/templates/_header.html.twig +++ b/ux.symfony.com/templates/_header.html.twig @@ -46,16 +46,17 @@ diff --git a/ux.symfony.com/templates/components/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 @@