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..c859396d8cd
--- /dev/null
+++ b/src/Toolkit/.gitignore
@@ -0,0 +1,6 @@
+vendor
+composer.lock
+.phpunit.result.cache
+var
+tests/ui/output
+tests/ui/screens
\ No newline at end of file
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..6c851b51a4f
--- /dev/null
+++ b/src/Toolkit/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## 2.23
+
+- Component 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/Makefile b/src/Toolkit/Makefile
new file mode 100644
index 00000000000..95b2f5fc3b7
--- /dev/null
+++ b/src/Toolkit/Makefile
@@ -0,0 +1,3 @@
+.PHONY: build
+build:
+ ./bin/build-registry.php --destination=registry/default
\ No newline at end of file
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/build-registry.php b/src/Toolkit/bin/build-registry.php
new file mode 100755
index 00000000000..112066c1cea
--- /dev/null
+++ b/src/Toolkit/bin/build-registry.php
@@ -0,0 +1,19 @@
+#!/usr/bin/env php
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+require_once __DIR__.'/../vendor/autoload.php';
+
+$app = new Symfony\Component\Console\Application('Symfony UX Toolkit Builder', '0.1');
+$compiler = new Symfony\UX\Toolkit\Compiler\RegistryCompiler(new Symfony\Component\Filesystem\Filesystem());
+$app->add(new Symfony\UX\Toolkit\Command\BuildRegistryCommand($compiler));
+$app->setDefaultCommand('ux:toolkit:build-registry', true);
+$app->run();
diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json
new file mode 100644
index 00000000000..4fc5c0f5b9a
--- /dev/null
+++ b/src/Toolkit/composer.json
@@ -0,0 +1,69 @@
+{
+ "name": "symfony/ux-toolkit",
+ "type": "symfony-bundle",
+ "description": "Twig Toolkit for Symfony",
+ "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.3",
+ "twig/extra-bundle": "^3.19|^4.0",
+ "twig/html-extra": "^3.19",
+ "twig/twig": "^2.12|^3.0",
+ "symfony/console": "^7.2",
+ "symfony/framework-bundle": "^6.4|^7.0",
+ "symfony/twig-bundle": "^6.4|^7.0",
+ "symfony/ux-twig-component": "^2.22",
+ "symfony/filesystem": "^7.2"
+ },
+ "require-dev": {
+ "symfony/finder": "6.4|^7.0",
+ "tales-from-a-dev/twig-tailwind-extra": "^0.3.0",
+ "zenstruck/console-test": "^1.7",
+ "symfony/http-client": "6.4|^7.0",
+ "symfony/stopwatch": "^7.2",
+ "symfony/phpunit-bridge": "^6.4|^7.0"
+ },
+ "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/doc/index.rst b/src/Toolkit/doc/index.rst
new file mode 100644
index 00000000000..2fa0659828d
--- /dev/null
+++ b/src/Toolkit/doc/index.rst
@@ -0,0 +1,28 @@
+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.
+It is part of `the Symfony UX initiative`_.
+
+Installation
+------------
+
+TODO
+
+Configuration
+-------------
+
+TODO
+
+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
+
+.. _`the Symfony UX initiative`: https://ux.symfony.com/
+#.. _`Twig Component`: https://symfony.com/bundles/ux-twig-component/current/index.html
diff --git a/src/Toolkit/phpunit.xml.dist b/src/Toolkit/phpunit.xml.dist
new file mode 100644
index 00000000000..1aace93babb
--- /dev/null
+++ b/src/Toolkit/phpunit.xml.dist
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests
+
+
+
+
+
+
+
+ src
+
+
+
diff --git a/src/Toolkit/registry/default/components/Alert.json b/src/Toolkit/registry/default/components/Alert.json
new file mode 100644
index 00000000000..66627193821
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Alert.json
@@ -0,0 +1,11 @@
+{
+ "name": "Alert",
+ "manifest": "components/Alert.json",
+ "theme": "",
+ "type": "component",
+ "code": "
\n Dependency test\n {% block content %}Alert{% endblock %}\n
\n",
+ "fingerprint": "9d173046a7c64cfc696f1a89aaac82a6",
+ "dependencies": [
+ "Button"
+ ]
+}
diff --git a/src/Toolkit/registry/default/components/Badge.json b/src/Toolkit/registry/default/components/Badge.json
new file mode 100644
index 00000000000..549650199fb
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Badge.json
@@ -0,0 +1,9 @@
+{
+ "name": "Badge",
+ "manifest": "components/Badge.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props variant = 'default', outline = false -%}\n{%- set style = html_cva(\n 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',\n variants: {\n variant: {\n default: \"border-transparent bg-primary text-primary-foreground hover:bg-primary/80\",\n secondary: \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n destructive: \"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80\",\n },\n outline: {\n true: \"text-foreground bg-white\",\n }\n },\n compoundVariants: [{\n variant: ['default'],\n outline: ['true'],\n class: 'border-primary',\n }, {\n variant: ['secondary'],\n outline: ['true'],\n class: 'border-secondary',\n }, {\n variant: ['destructive'],\n outline: ['true'],\n class: 'border-destructive',\n },]\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "60d4bcc6f49889e80835dae477d51a11",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Button.json b/src/Toolkit/registry/default/components/Button.json
new file mode 100644
index 00000000000..ba2d02871e3
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Button.json
@@ -0,0 +1,9 @@
+{
+ "name": "Button",
+ "manifest": "components/Button.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props variant = 'default', outline = false, size = 'default' -%}\n{%- set style = html_cva(\n 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',\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n secondary: \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n destructive: \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n ghost: \"hover:bg-accent hover:text-accent-foreground\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n outline: {\n true: \"text-foreground bg-white\",\n },\n size: {\n default: \"h-10 px-4 py-2\",\n sm: \"h-9 rounded-md px-3\",\n lg: \"h-11 rounded-md px-8\",\n icon: \"h-10 w-10\",\n },\n },\n compoundVariants: [{\n variant: ['default'],\n outline: ['true'],\n class: 'border-primary',\n }, {\n variant: ['secondary'],\n outline: ['true'],\n class: 'border-secondary',\n }, {\n variant: ['destructive'],\n outline: ['true'],\n class: 'border-destructive',\n },]\n) -%}\n\n\n",
+ "fingerprint": "e8af4da83dc7b708414e4c5f2985ffa8",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Card.json b/src/Toolkit/registry/default/components/Card.json
new file mode 100644
index 00000000000..76f083ecbd4
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Card.json
@@ -0,0 +1,15 @@
+{
+ "name": "Card",
+ "manifest": "components/Card.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'rounded-xl border bg-card text-card-foreground shadow',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "3dbab35abce06e5c4480927c0262c887",
+ "dependencies": [
+ "CardContent",
+ "CardDescription",
+ "CardFooter",
+ "CardHeader",
+ "CardTitle"
+ ]
+}
diff --git a/src/Toolkit/registry/default/components/Card/CardContent.json b/src/Toolkit/registry/default/components/Card/CardContent.json
new file mode 100644
index 00000000000..40707535d5e
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Card/CardContent.json
@@ -0,0 +1,9 @@
+{
+ "name": "CardContent",
+ "manifest": "components/Card/CardContent.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'p-6 pt-0',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "6d82a1d647e98c8da2d89a1f91085a5d",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Card/CardDescription.json b/src/Toolkit/registry/default/components/Card/CardDescription.json
new file mode 100644
index 00000000000..622219e22ec
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Card/CardDescription.json
@@ -0,0 +1,9 @@
+{
+ "name": "CardDescription",
+ "manifest": "components/Card/CardDescription.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'text-sm text-muted-foreground',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "581b2452e39ddcce59114e83699d91e7",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Card/CardFooter.json b/src/Toolkit/registry/default/components/Card/CardFooter.json
new file mode 100644
index 00000000000..aba0396c442
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Card/CardFooter.json
@@ -0,0 +1,9 @@
+{
+ "name": "CardFooter",
+ "manifest": "components/Card/CardFooter.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'flex items-center p-6 pt-0',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "e832021c9cf976ced3995fd270e7ff70",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Card/CardHeader.json b/src/Toolkit/registry/default/components/Card/CardHeader.json
new file mode 100644
index 00000000000..49e96f7552a
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Card/CardHeader.json
@@ -0,0 +1,9 @@
+{
+ "name": "CardHeader",
+ "manifest": "components/Card/CardHeader.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'flex flex-col space-y-1.5 p-6',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "fc0e5987e8cdb25615177dbfae2fdc1c",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Card/CardTitle.json b/src/Toolkit/registry/default/components/Card/CardTitle.json
new file mode 100644
index 00000000000..a32204d6f7c
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Card/CardTitle.json
@@ -0,0 +1,9 @@
+{
+ "name": "CardTitle",
+ "manifest": "components/Card/CardTitle.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'font-semibold leading-none tracking-tight',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "d7d884e4f029c8e8501521eabc735b4e",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Grid.json b/src/Toolkit/registry/default/components/Grid.json
new file mode 100644
index 00000000000..2a9b23b4bc6
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Grid.json
@@ -0,0 +1,12 @@
+{
+ "name": "Grid",
+ "manifest": "components/Grid.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props\n xs = 0,\n sm = 0,\n md = 0,\n lg = 0,\n xl = 0,\n xsOffset = 0,\n smOffset = 0,\n mdOffset = 0,\n lgOffset = 0,\n xlOffset = 0,\n align = '',\n justify = '',\n direction = '',\n-%}\n\n{% set xsSize = false %}{% if xs > 0 %}{% set xsSize = \"true\" %}{% endif %}\n{% set smSize = false %}{% if sm > 0 %}{% set smSize = \"true\" %}{% endif %}\n{% set mdSize = false %}{% if md > 0 %}{% set mdSize = \"true\" %}{% endif %}\n{% set lgSize = false %}{% if lg > 0 %}{% set lgSize = \"true\" %}{% endif %}\n{% set xlSize = false %}{% if xl > 0 %}{% set xlSize = \"true\" %}{% endif %}\n\n{% set xsOffsetSize = false %}{% if xsOffset > 0 %}{% set xsOffsetSize = \"true\" %}{% endif %}\n{% set smOffsetSize = false %}{% if smOffset > 0 %}{% set smOffsetSize = \"true\" %}{% endif %}\n{% set mdOffsetSize = false %}{% if mdOffset > 0 %}{% set mdOffsetSize = \"true\" %}{% endif %}\n{% set lgOffsetSize = false %}{% if lgOffset > 0 %}{% set lgOffsetSize = \"true\" %}{% endif %}\n{% set xlOffsetSize = false %}{% if xlOffset > 0 %}{% set xlOffsetSize = \"true\" %}{% endif %}\n\n{% set alignClass = '' %}\n{% if align == 'start' %}{% set alignClass = 'items-start' %}\n{% elseif align == 'center' %}{% set alignClass = 'items-center' %}\n{% elseif align == 'end' %}{% set alignClass = 'items-end' %}\n{% endif %}\n\n{% set justifyClass = '' %}\n{% if justify == 'start' %}{% set justifyClass = 'justify-start' %}\n{% elseif justify == 'center' %}{% set justifyClass = 'justify-center' %}\n{% elseif justify == 'end' %}{% set justifyClass = 'justify-end' %}\n{% elseif justify == 'between' %}{% set justifyClass = 'justify-between' %}\n{% elseif justify == 'around' %}{% set justifyClass = 'justify-around' %}\n{% elseif justify == 'evenly' %}{% set justifyClass = 'justify-evenly' %}\n{% endif %}\n\n{% set directionClass = '' %}\n{% if direction == 'row' %}{% set directionClass = 'flex-row' %}\n{% elseif direction == 'column' %}{% set directionClass = 'flex-col' %}\n{% endif %}\n\n{%- set style = html_cva(\n base: 'grid',\n variants: {\n xsSize: {\n \"true\": 'grid-cols-' ~ xs,\n },\n smSize: {\n \"true\": 'sm:grid-cols-' ~ sm,\n },\n mdSize: {\n \"true\": 'md:grid-cols-' ~ md,\n },\n lgSize: {\n \"true\": 'lg:grid-cols-' ~ lg,\n },\n xlSize: {\n \"true\": 'xl:grid-cols-' ~ xl,\n },\n xsOffsetSize: {\n \"true\": 'col-start-' ~ (xsOffset + 1),\n },\n smOffsetSize: {\n \"true\": 'sm:col-start-' ~ (smOffset + 1),\n },\n mdOffsetSize: {\n \"true\": 'md:col-start-' ~ (mdOffset + 1),\n },\n lgOffsetSize: {\n \"true\": 'lg:col-start-' ~ (lgOffset + 1),\n },\n xlOffsetSize: {\n \"true\": 'xl:col-start-' ~ (xlOffset + 1),\n },\n },\n) -%}\n\n {% block content %}{% endblock %}\n
",
+ "fingerprint": "478dbf00b7075704f05c8a629e4f9c54",
+ "dependencies": [
+ "Col",
+ "Row"
+ ]
+}
diff --git a/src/Toolkit/registry/default/components/Grid/Col.json b/src/Toolkit/registry/default/components/Grid/Col.json
new file mode 100644
index 00000000000..cd1b3e0147d
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Grid/Col.json
@@ -0,0 +1,9 @@
+{
+ "name": "Col",
+ "manifest": "components/Grid/Col.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props\n xs = 0,\n sm = 0,\n md = 0,\n lg = 0,\n xl = 0,\n xsOffset = 0,\n smOffset = 0,\n mdOffset = 0,\n lgOffset = 0,\n xlOffset = 0,\n first = false,\n last = false,\n-%}\n\n{% set xsCols = false %}{% if xs > 0 %}{% set xsCols = \"true\" %}{% endif %}\n{% set smCols = false %}{% if sm > 0 %}{% set smCols = \"true\" %}{% endif %}\n{% set mdCols = false %}{% if md > 0 %}{% set mdCols = \"true\" %}{% endif %}\n{% set lgCols = false %}{% if lg > 0 %}{% set lgCols = \"true\" %}{% endif %}\n{% set xlCols = false %}{% if xl > 0 %}{% set xlCols = \"true\" %}{% endif %}\n\n{% set xsOffsetCols = false %}{% if xsOffset > 0 %}{% set xsOffsetCols = \"true\" %}{% endif %}\n{% set smOffsetCols = false %}{% if smOffset > 0 %}{% set smOffsetCols = \"true\" %}{% endif %}\n{% set mdOffsetCols = false %}{% if mdOffset > 0 %}{% set mdOffsetCols = \"true\" %}{% endif %}\n{% set lgOffsetCols = false %}{% if lgOffset > 0 %}{% set lgOffsetCols = \"true\" %}{% endif %}\n{% set xlOffsetCols = false %}{% if xlOffset > 0 %}{% set xlOffsetCols = \"true\" %}{% endif %}\n\n{% set firstClass = false %}{% if first %}{% set firstClass = \"true\" %}{% endif %}\n{% set lastClass = false %}{% if last %}{% set lastClass = \"true\" %}{% endif %}\n\n{%- set style = html_cva(\n base: '',\n variants: {\n xsCols: {\n \"true\": 'col-span-' ~ xs,\n },\n smCols: {\n \"true\": 'sm:col-span-' ~ sm,\n },\n mdCols: {\n \"true\": 'md:col-span-' ~ md,\n },\n lgCols: {\n \"true\": 'lg:col-span-' ~ lg,\n },\n xlCols: {\n \"true\": 'xl:col-span-' ~ xl,\n },\n xsOffsetCols: {\n \"true\": 'col-start-' ~ (xsOffset + 1),\n },\n smOffsetCols: {\n \"true\": 'sm:col-start-' ~ (smOffset + 1),\n },\n mdOffsetCols: {\n \"true\": 'md:col-start-' ~ (mdOffset + 1),\n },\n lgOffsetCols: {\n \"true\": 'lg:col-start-' ~ (lgOffset + 1),\n },\n xlOffsetCols: {\n \"true\": 'xl:col-start-' ~ (xlOffset + 1),\n },\n firstClass: {\n \"true\": 'order-first',\n },\n lastClass: {\n \"true\": 'order-last',\n },\n },\n) -%}\n\n {% block content %}{% endblock %}\n
",
+ "fingerprint": "cf18a5ece91ee1e4b0a2a23ea4d50d76",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Grid/Row.json b/src/Toolkit/registry/default/components/Grid/Row.json
new file mode 100644
index 00000000000..b651f674991
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Grid/Row.json
@@ -0,0 +1,9 @@
+{
+ "name": "Row",
+ "manifest": "components/Grid/Row.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props\n start = 'auto',\n center = false,\n end = false,\n top = false,\n middle = false,\n bottom = false,\n around = false,\n between = false,\n reverse = false,\n-%}\n\n{% set startClass = false %}{% if start != 'auto' %}{% set startClass = \"true\" %}{% endif %}\n{% set centerClass = false %}{% if center %}{% set centerClass = \"true\" %}{% endif %}\n{% set endClass = false %}{% if end %}{% set endClass = \"true\" %}{% endif %}\n{% set topClass = false %}{% if top %}{% set topClass = \"true\" %}{% endif %}\n{% set middleClass = false %}{% if middle %}{% set middleClass = \"true\" %}{% endif %}\n{% set bottomClass = false %}{% if bottom %}{% set bottomClass = \"true\" %}{% endif %}\n{% set aroundClass = false %}{% if around %}{% set aroundClass = \"true\" %}{% endif %}\n{% set betweenClass = false %}{% if between %}{% set betweenClass = \"true\" %}{% endif %}\n{% set reverseClass = false %}{% if reverse %}{% set reverseClass = \"true\" %}{% endif %}\n\n{%- set style = html_cva(\n base: 'flex flex-wrap',\n variants: {\n startClass: {\n \"true\": 'justify-start',\n },\n centerClass: {\n \"true\": 'justify-center',\n },\n endClass: {\n \"true\": 'justify-end',\n },\n topClass: {\n \"true\": 'items-start',\n },\n middleClass: {\n \"true\": 'items-center',\n },\n bottomClass: {\n \"true\": 'items-end',\n },\n aroundClass: {\n \"true\": 'justify-around',\n },\n betweenClass: {\n \"true\": 'justify-between',\n },\n reverseClass: {\n \"true\": 'flex-row-reverse',\n },\n },\n) -%}\n\n {% block content %}{% endblock %}\n
",
+ "fingerprint": "0943905f322e9765cb66c65daa5bccb8",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Navbar.json b/src/Toolkit/registry/default/components/Navbar.json
new file mode 100644
index 00000000000..f8c3cf9862a
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Navbar.json
@@ -0,0 +1,9 @@
+{
+ "name": "Navbar",
+ "manifest": "components/Navbar.json",
+ "theme": "",
+ "type": "component",
+ "code": "\n",
+ "fingerprint": "213add68cdffa8aa3a270d7629bb489b",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table.json b/src/Toolkit/registry/default/components/Table.json
new file mode 100644
index 00000000000..60cc3fe820c
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table.json
@@ -0,0 +1,17 @@
+{
+ "name": "Table",
+ "manifest": "components/Table.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'relative w-full overflow-auto',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "1ecd968d9670e2176356b851d86f3b26",
+ "dependencies": [
+ "TableBody",
+ "TableCaption",
+ "TableCell",
+ "TableFooter",
+ "TableHead",
+ "TableHeader",
+ "TableRow"
+ ]
+}
diff --git a/src/Toolkit/registry/default/components/Table/Row.json b/src/Toolkit/registry/default/components/Table/Row.json
new file mode 100644
index 00000000000..153345d59e8
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/Row.json
@@ -0,0 +1,9 @@
+{
+ "name": "Row",
+ "manifest": "components/Table/Row.json",
+ "theme": "",
+ "type": "component",
+ "code": "\n {% block content %}Row{% endblock %}\n
\n",
+ "fingerprint": "0fded51fb960081206bbb62d932951a0",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableBody.json b/src/Toolkit/registry/default/components/Table/TableBody.json
new file mode 100644
index 00000000000..ce0b5618066
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableBody.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableBody",
+ "manifest": "components/Table/TableBody.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: '[&_tr:last-child]:border-0',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n\n",
+ "fingerprint": "cc7177549ce5e93b4e5588c1ae01e0f7",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableCaption.json b/src/Toolkit/registry/default/components/Table/TableCaption.json
new file mode 100644
index 00000000000..2ac878f8d5a
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableCaption.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableCaption",
+ "manifest": "components/Table/TableCaption.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'text-muted-foreground mt-4 text-sm',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n\n",
+ "fingerprint": "96777b5ee80f417361d81a9975613e56",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableCell.json b/src/Toolkit/registry/default/components/Table/TableCell.json
new file mode 100644
index 00000000000..87967dd2105
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableCell.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableCell",
+ "manifest": "components/Table/TableCell.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n | \n",
+ "fingerprint": "998e5e25f363731f40cb0d64797dadfd",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableFooter.json b/src/Toolkit/registry/default/components/Table/TableFooter.json
new file mode 100644
index 00000000000..31fb859b759
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableFooter.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableFooter",
+ "manifest": "components/Table/TableFooter.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n\n",
+ "fingerprint": "cad323f00c8108b98851df8aac2819ad",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableHead.json b/src/Toolkit/registry/default/components/Table/TableHead.json
new file mode 100644
index 00000000000..9c58816e21d
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableHead.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableHead",
+ "manifest": "components/Table/TableHead.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n | \n",
+ "fingerprint": "a0a72a22e9d7253be3336e60304424da",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableHeader.json b/src/Toolkit/registry/default/components/Table/TableHeader.json
new file mode 100644
index 00000000000..ace0d886fd3
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableHeader.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableHeader",
+ "manifest": "components/Table/TableHeader.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: '[&_tr]:border-b',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "ad7e6c772f20c83f09dafe9ee8687f8f",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/components/Table/TableRow.json b/src/Toolkit/registry/default/components/Table/TableRow.json
new file mode 100644
index 00000000000..0885f811955
--- /dev/null
+++ b/src/Toolkit/registry/default/components/Table/TableRow.json
@@ -0,0 +1,9 @@
+{
+ "name": "TableRow",
+ "manifest": "components/Table/TableRow.json",
+ "theme": "",
+ "type": "component",
+ "code": "{%- props -%}\n{%- set style = html_cva(\n base: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',\n variants: {},\n compoundVariants: []\n) -%}\n\n\n {% block content %}{% endblock %}\n
\n",
+ "fingerprint": "a3e2c0ce5de795c63afd94ce8b34f5aa",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/examples/Badge.json b/src/Toolkit/registry/default/examples/Badge.json
new file mode 100644
index 00000000000..7ce20f8d24b
--- /dev/null
+++ b/src/Toolkit/registry/default/examples/Badge.json
@@ -0,0 +1,9 @@
+{
+ "name": "Badge",
+ "manifest": "examples/Badge.json",
+ "theme": "",
+ "type": "example",
+ "code": "Badge\n",
+ "fingerprint": "6eeb538fce2bda2152dd7af729d01253",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/examples/BadgeOutline.json b/src/Toolkit/registry/default/examples/BadgeOutline.json
new file mode 100644
index 00000000000..d6212953894
--- /dev/null
+++ b/src/Toolkit/registry/default/examples/BadgeOutline.json
@@ -0,0 +1,9 @@
+{
+ "name": "BadgeOutline",
+ "manifest": "examples/BadgeOutline.json",
+ "theme": "",
+ "type": "example",
+ "code": "Badge\nBadge\nBadge\n",
+ "fingerprint": "ed027eb6cb73152ed3e7291fc39f6c07",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/examples/Button.json b/src/Toolkit/registry/default/examples/Button.json
new file mode 100644
index 00000000000..e9b8e00da63
--- /dev/null
+++ b/src/Toolkit/registry/default/examples/Button.json
@@ -0,0 +1,9 @@
+{
+ "name": "Button",
+ "manifest": "examples/Button.json",
+ "theme": "",
+ "type": "example",
+ "code": "Click me\n",
+ "fingerprint": "6449c62a6ead499bd1402d6b8506e45d",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/examples/Grid.json b/src/Toolkit/registry/default/examples/Grid.json
new file mode 100644
index 00000000000..b7d3baac6e4
--- /dev/null
+++ b/src/Toolkit/registry/default/examples/Grid.json
@@ -0,0 +1,9 @@
+{
+ "name": "Grid",
+ "manifest": "examples/Grid.json",
+ "theme": "",
+ "type": "example",
+ "code": "\n col\n col\n col\n large column\n col\n col\n",
+ "fingerprint": "5f14717e9988e4667a4ef967e7b51b0e",
+ "dependencies": []
+}
diff --git a/src/Toolkit/registry/default/registry.json b/src/Toolkit/registry/default/registry.json
new file mode 100644
index 00000000000..57577ce7f94
--- /dev/null
+++ b/src/Toolkit/registry/default/registry.json
@@ -0,0 +1,200 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "homepage": "https://...",
+ "items": [
+ {
+ "name": "Alert",
+ "manifest": "components/Alert.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": [
+ "Button"
+ ]
+ },
+ {
+ "name": "Badge",
+ "manifest": "components/Badge.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Button",
+ "manifest": "components/Button.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Card",
+ "manifest": "components/Card.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": [
+ "CardContent",
+ "CardDescription",
+ "CardFooter",
+ "CardHeader",
+ "CardTitle"
+ ]
+ },
+ {
+ "name": "CardContent",
+ "manifest": "components/Card/CardContent.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "CardDescription",
+ "manifest": "components/Card/CardDescription.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "CardFooter",
+ "manifest": "components/Card/CardFooter.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "CardHeader",
+ "manifest": "components/Card/CardHeader.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "CardTitle",
+ "manifest": "components/Card/CardTitle.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Grid",
+ "manifest": "components/Grid.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": [
+ "Col",
+ "Row"
+ ]
+ },
+ {
+ "name": "Col",
+ "manifest": "components/Grid/Col.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Row",
+ "manifest": "components/Grid/Row.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Navbar",
+ "manifest": "components/Navbar.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Table",
+ "manifest": "components/Table.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": [
+ "TableBody",
+ "TableCaption",
+ "TableCell",
+ "TableFooter",
+ "TableHead",
+ "TableHeader",
+ "TableRow"
+ ]
+ },
+ {
+ "name": "TableBody",
+ "manifest": "components/Table/TableBody.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "TableCaption",
+ "manifest": "components/Table/TableCaption.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "TableCell",
+ "manifest": "components/Table/TableCell.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "TableFooter",
+ "manifest": "components/Table/TableFooter.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "TableHead",
+ "manifest": "components/Table/TableHead.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "TableHeader",
+ "manifest": "components/Table/TableHeader.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "TableRow",
+ "manifest": "components/Table/TableRow.json",
+ "theme": "",
+ "type": "component",
+ "dependencies": []
+ },
+ {
+ "name": "Badge",
+ "manifest": "examples/Badge.json",
+ "theme": "",
+ "type": "example",
+ "dependencies": []
+ },
+ {
+ "name": "BadgeOutline",
+ "manifest": "examples/BadgeOutline.json",
+ "theme": "",
+ "type": "example",
+ "dependencies": []
+ },
+ {
+ "name": "Button",
+ "manifest": "examples/Button.json",
+ "theme": "",
+ "type": "example",
+ "dependencies": []
+ },
+ {
+ "name": "Grid",
+ "manifest": "examples/Grid.json",
+ "theme": "",
+ "type": "example",
+ "dependencies": []
+ }
+ ]
+}
diff --git a/src/Toolkit/src/Command/BuildRegistryCommand.php b/src/Toolkit/src/Command/BuildRegistryCommand.php
new file mode 100644
index 00000000000..9fb72aab629
--- /dev/null
+++ b/src/Toolkit/src/Command/BuildRegistryCommand.php
@@ -0,0 +1,123 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Command;
+
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Finder\Finder;
+use Symfony\UX\Toolkit\Compiler\RegistryCompiler;
+use Symfony\UX\Toolkit\Registry\Registry;
+use Symfony\UX\Toolkit\Registry\RegistryItem;
+
+/**
+ * @author Jean-François Lépine
+ * @author Hugo Alliaume
+ *
+ * @internal
+ */
+#[AsCommand(
+ name: 'ux:toolkit:build-registry',
+ description: 'This command allows to distribute your components, and build the registry.'
+)]
+class BuildRegistryCommand extends Command
+{
+ public function __construct(
+ private readonly RegistryCompiler $compiler,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption(
+ 'template-directory',
+ 't',
+ InputOption::VALUE_REQUIRED,
+ 'The directory where the templates are stored.',
+ 'templates'
+ )
+ ->addOption(
+ 'destination',
+ 'r',
+ InputOption::VALUE_REQUIRED,
+ 'The directory where the registry will be stored.',
+ 'registry'
+ )
+ ->addArgument('name', InputArgument::OPTIONAL, 'The name of the component.', '')
+ ->addArgument('homepage', InputArgument::OPTIONAL, 'The homepage of the component.', 'https://...')
+ ->addOption(
+ 'authors',
+ '',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ 'The authors of the component.',
+ []
+ )
+ ->addOption(
+ 'licenses',
+ '',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ 'The licenses of the component.',
+ []
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $io->title('Building the registry...');
+
+ $registry = Registry::empty();
+ $registry->setName($input->getArgument('name'));
+ $registry->setHomepage($input->getArgument('homepage'));
+
+ foreach ($input->getOption('authors') as $author) {
+ // author is a string like "name "
+ $email = null;
+ if (preg_match('/^(.+) <(.+)>$/', $author, $matches)) {
+ $author = ['name' => $matches[1], 'email' => $matches[2]];
+ }
+ $registry->addAuthor($author, $email);
+ }
+
+ foreach ($input->getOption('licenses') as $license) {
+ $registry->addLicense($license);
+ }
+
+ $finderTemplates = Finder::create()
+ ->files()
+ ->in($input->getOption('template-directory'))
+ ->name('*.html.twig')
+ ->sortByName();
+
+ $table = [];
+ foreach ($finderTemplates as $file) {
+ $table[] = [$file->getRelativePathname()];
+ $registry->add(RegistryItem::fromTwigFile($file));
+ }
+
+ $registryDir = $input->getOption('destination');
+ $this->compiler->compile($registry, $registryDir);
+
+ $io->table(['Templates'], $table);
+
+ $io->success('The registry has been successfully built.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Toolkit/src/Command/DebugUxToolkitCommand.php b/src/Toolkit/src/Command/DebugUxToolkitCommand.php
new file mode 100644
index 00000000000..0cc16d599ac
--- /dev/null
+++ b/src/Toolkit/src/Command/DebugUxToolkitCommand.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\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\Style\SymfonyStyle;
+use Symfony\UX\Toolkit\ComponentRepository\CurrentTheme;
+use Symfony\UX\Toolkit\Registry\RegistryFactory;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+#[AsCommand(
+ name: 'debug:ux:toolkit',
+ description: 'This command list all components available in the current theme.'
+)]
+class DebugUxToolkitCommand extends Command
+{
+ public function __construct(
+ private readonly CurrentTheme $currentTheme,
+ private readonly RegistryFactory $registryFactory,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $repository = $this->currentTheme->getIdentity();
+ $finder = $this->currentTheme->getRepository()->fetch($repository);
+ $registry = $this->registryFactory->create($finder);
+
+ $io->title('Current theme:');
+ $io->note('Update your config/packages/ux_toolkit.yaml to change the current theme.');
+ $io->table(['Vendor', 'Package'], [[$repository->getVendor(), $repository->getPackage()]]);
+
+ $io->title('Available components:');
+ $table = [];
+ foreach ($registry->all() as $component) {
+ $table[] = [$component->name];
+ }
+
+ $io->table(['Component'], $table);
+
+ $io->note('Run "symfony console ux:toolkit:install " to install a component.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Toolkit/src/Command/UxToolkitInstallCommand.php b/src/Toolkit/src/Command/UxToolkitInstallCommand.php
new file mode 100644
index 00000000000..86d3908d0e6
--- /dev/null
+++ b/src/Toolkit/src/Command/UxToolkitInstallCommand.php
@@ -0,0 +1,119 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Command;
+
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\UX\Toolkit\Compiler\Exception\TwigComponentAlreadyExist;
+use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler;
+use Symfony\UX\Toolkit\ComponentRepository\CurrentTheme;
+use Symfony\UX\Toolkit\Registry\RegistryFactory;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+#[AsCommand(
+ name: 'ux:toolkit:install',
+ description: 'This command will install a new UX Component in your project',
+)]
+class UxToolkitInstallCommand extends Command
+{
+ public function __construct(
+ private readonly CurrentTheme $currentTheme,
+ private readonly RegistryFactory $registryFactory,
+ private readonly TwigComponentCompiler $compiler,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addArgument('component', InputArgument::REQUIRED, 'The component name (Ex: button) or repository URL')
+ ->addOption(
+ 'destination',
+ 'd',
+ InputArgument::OPTIONAL,
+ 'The destination directory',
+ 'templates/components'
+ )
+ ->addOption('overwrite', 'o', InputOption::VALUE_NONE, 'Overwrite the component if it already exists')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $repository = $this->currentTheme->getIdentity();
+
+ $io->info(
+ \sprintf(
+ 'Downloading medata for %s/%s..',
+ $repository->getVendor(),
+ $repository->getPackage(),
+ )
+ );
+
+ $name = ucfirst($input->getArgument('component'));
+ $finder = $this->currentTheme->getRepository()->fetch($repository);
+ $registry = $this->registryFactory->create($finder);
+
+ if (!$registry->has($name)) {
+ $io->error(\sprintf('The component "%s" does not exist.', $name));
+
+ return Command::FAILURE;
+ }
+ $component = $registry->get($name);
+
+ $destination = $input->getOption('destination');
+ try {
+ $io->info(\sprintf('Installing component "%s"...', $component->name));
+ $this->compiler->compile($registry, $component, $destination);
+ } catch (TwigComponentAlreadyExist $e) {
+
+ if ($input->getOption('overwrite')) {
+ // again
+ $this->compiler->compile($registry, $component, $destination, true);
+
+ $io->success(\sprintf('The component "%s" has been installed.', $component->name));
+ return Command::SUCCESS;
+ }
+
+ if (!$input->isInteractive()) {
+ $io->error(\sprintf('The component "%s" already exists.', $component->name));
+
+ return Command::FAILURE;
+ }
+
+ if (!$io->confirm(
+ \sprintf('The component "%s" already exists. Do you want to overwrite it?', $component->name)
+ )) {
+ return Command::FAILURE;
+ }
+
+ // again
+ $this->compiler->compile($registry, $component, $destination, true);
+ }
+
+ $io->success(\sprintf('The component "%s" has been installed.', $component->name));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Toolkit/src/Compiler/Exception/TwigComponentAlreadyExist.php b/src/Toolkit/src/Compiler/Exception/TwigComponentAlreadyExist.php
new file mode 100644
index 00000000000..091e8a01159
--- /dev/null
+++ b/src/Toolkit/src/Compiler/Exception/TwigComponentAlreadyExist.php
@@ -0,0 +1,21 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Compiler\Exception;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+class TwigComponentAlreadyExist extends \LogicException
+{
+}
diff --git a/src/Toolkit/src/Compiler/RegistryCompiler.php b/src/Toolkit/src/Compiler/RegistryCompiler.php
new file mode 100644
index 00000000000..46d74e64aaa
--- /dev/null
+++ b/src/Toolkit/src/Compiler/RegistryCompiler.php
@@ -0,0 +1,95 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Compiler;
+
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Filesystem\Path;
+use Symfony\UX\Toolkit\Registry\Registry;
+use Symfony\UX\Toolkit\Registry\RegistryItem;
+use Symfony\UX\Toolkit\Registry\RegistryItemType;
+
+/**
+ * @author Jean-François Lépine
+ * @author Hugo Alliaume
+ *
+ * @internal
+ */
+final class RegistryCompiler
+{
+ public function __construct(
+ private readonly Filesystem $filesystem,
+ ) {
+ }
+
+ public function compile(Registry $registry, string $registryDir): void
+ {
+ $this->filesystem->mkdir($registryDir);
+
+ $registryJson = [
+ '$schema' => 'https://ui.shadcn.com/schema/registry.json',
+ 'name' => $registry->getName(),
+ 'licenses' => $registry->getLicenses(),
+ 'authors' => $registry->getAuthors(),
+ 'homepage' => $registry->getHomepage(),
+ 'items' => [],
+ ];
+ $registryJson = array_filter($registryJson);
+
+ foreach ($registry->all() as $item) {
+ $itemPath = Path::join($registryDir, $item->theme, $item->type->value.'s', $item->parentName ?: '', $item->name.'.json');
+
+ $itemJson = [
+ 'name' => $item->name,
+ 'manifest' => Path::makeRelative($itemPath, $registryDir),
+ 'theme' => $item->theme,
+ 'type' => $item->type->value,
+ 'code' => $item->code,
+ 'fingerprint' => md5($item->code),
+ 'dependencies' => $this->getDependencies($registry, $item),
+ ];
+
+ $this->filesystem->dumpFile($itemPath, json_encode($itemJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n");
+
+ unset($itemJson['code']);
+ unset($itemJson['fingerprint']);
+ $registryJson['items'][] = $itemJson;
+ }
+
+ $registryPath = Path::join($registryDir, 'registry.json');
+ $this->filesystem->dumpFile($registryPath, json_encode($registryJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ private function getDependencies(Registry $registry, RegistryItem $component): array
+ {
+ if (RegistryItemType::Component !== $component->type) {
+ return [];
+ }
+
+ $dependencies = [];
+
+ foreach ($registry->all() as $item) {
+ if ($item->theme !== $component->theme || $item->type !== $component->type) {
+ continue;
+ }
+
+ if ($item->parentName === $component->name) {
+ $dependencies[] = $item->name;
+ }
+
+ if (str_contains($component->code, 'name)) {
+ $dependencies[] = $item->name;
+ }
+ }
+
+ return $dependencies;
+ }
+}
diff --git a/src/Toolkit/src/Compiler/TwigComponentCompiler.php b/src/Toolkit/src/Compiler/TwigComponentCompiler.php
new file mode 100644
index 00000000000..c0e0f5f91f6
--- /dev/null
+++ b/src/Toolkit/src/Compiler/TwigComponentCompiler.php
@@ -0,0 +1,71 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Compiler;
+
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\Toolkit\Compiler\Exception\TwigComponentAlreadyExist;
+use Symfony\UX\Toolkit\Registry\DependenciesResolver;
+use Symfony\UX\Toolkit\Registry\Registry;
+use Symfony\UX\Toolkit\Registry\RegistryItem;
+use Symfony\UX\Toolkit\Registry\RegistryItemType;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+final class TwigComponentCompiler
+{
+ public function __construct(
+ private readonly ?string $prefix,
+ private readonly DependenciesResolver $dependenciesResolver,
+ ) {
+ }
+
+ public function compile(
+ Registry $registry,
+ RegistryItem $item,
+ string $directory,
+ bool $overwrite = false,
+ ): void {
+ // resolve dependencies (avoid circular dependencies)
+ $this->dependenciesResolver->resolve($registry);
+
+ $filesystem = new Filesystem();
+ if (!$filesystem->exists($directory)) {
+ $filesystem->mkdir($directory);
+ }
+ $componentsToInstall = array_merge([$item->name], $item->getDependencies());
+ foreach ($componentsToInstall as $componentName) {
+ $this->installComponent($registry->get($componentName), $directory, $filesystem, $overwrite);
+ }
+ }
+
+ private function installComponent(RegistryItem $item, string $directory, Filesystem $filesystem, bool $overwrite): void
+ {
+ if (RegistryItemType::Component !== $item->type) {
+ return;
+ }
+
+ $filename = implode(\DIRECTORY_SEPARATOR, [
+ $directory,
+ $this->prefix,
+ $item->name.'.html.twig',
+ ]);
+
+ if ($filesystem->exists($filename) && !$overwrite) {
+ throw new TwigComponentAlreadyExist("The component '{$item->name}' already exists.", 0, null);
+ }
+
+ $filesystem->dumpFile($filename, $item->code);
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/ComponentRepository.php b/src/Toolkit/src/ComponentRepository/ComponentRepository.php
new file mode 100644
index 00000000000..e3d361a7610
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/ComponentRepository.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\ComponentRepository;
+
+use Symfony\Component\Finder\Finder;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+interface ComponentRepository
+{
+ public function fetch(RepositoryIdentity $repository): Finder;
+}
diff --git a/src/Toolkit/src/ComponentRepository/CurrentTheme.php b/src/Toolkit/src/ComponentRepository/CurrentTheme.php
new file mode 100644
index 00000000000..ff176061c34
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/CurrentTheme.php
@@ -0,0 +1,42 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\ComponentRepository;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+final class CurrentTheme
+{
+ private ComponentRepository $repository;
+ private RepositoryIdentity $identity;
+
+ public function __construct(
+ string $theme,
+ RepositoryFactory $repositoryFactory,
+ RepositoryIdentifier $repositoryIdentifier,
+ ) {
+ $this->identity = $repositoryIdentifier->identify($theme);
+ $this->repository = $repositoryFactory->factory($this->identity);
+ }
+
+ public function getRepository(): ComponentRepository
+ {
+ return $this->repository;
+ }
+
+ public function getIdentity(): RepositoryIdentity
+ {
+ return $this->identity;
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/GithubRepository.php b/src/Toolkit/src/ComponentRepository/GithubRepository.php
new file mode 100644
index 00000000000..d99460be43f
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/GithubRepository.php
@@ -0,0 +1,100 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\ComponentRepository;
+
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+class GithubRepository implements ComponentRepository
+{
+ public function __construct(
+ private readonly Filesystem $filesystem,
+ private readonly ?HttpClientInterface $httpClient = null,
+ ) {
+ if (!class_exists(HttpClient::class)) {
+ throw new \LogicException('You must install "symfony/http-client" to use ux-toolkit with remote component. Try running "composer require symfony/http-client".');
+ }
+
+ if (!class_exists(\ZipArchive::class)) {
+ throw new \LogicException('You must have the Zip extension installed to use ux-toolkit with remote components.');
+ }
+ }
+
+ public function fetch(RepositoryIdentity $component): Finder
+ {
+ // download a zip file of the github repository, place it in a temporary directory in cache
+ $zipUrl = \sprintf(
+ 'http://github.com/%s/%s/archive/%s.zip',
+ $component->getVendor(),
+ $component->getPackage(),
+ $component->getVersion(),
+ );
+
+ $destination = $this->getCacheDir();
+ $finder = new Finder();
+ $finder->files()->in($destination);
+
+ if ($this->filesystem->exists($destination.'/registry.json')) {
+ return $finder;
+ }
+
+ $zipFile = $destination.'/'.basename($zipUrl);
+ $response = $this->httpClient->request('GET', $zipUrl, [
+ 'sink' => $zipFile,
+ ]);
+
+ // Ensure the request was successful
+ if (200 !== $response->getStatusCode()) {
+ throw new \RuntimeException(\sprintf('Failed to download the file from "%s".', $zipUrl));
+ }
+
+ // Ensure response contains valid headers
+ $headers = $response->getHeaders();
+ if (!isset($headers['content-type']) || !\in_array('application/zip', $headers['content-type'])) {
+ throw new \RuntimeException(\sprintf('The file from "%s" is not a valid zip file.', $zipUrl));
+ }
+
+ // Flush the response to the file
+ $this->filesystem->dumpFile($zipFile, $response->getContent());
+
+ // unzip the file
+ $zip = new \ZipArchive();
+ $zip->open($zipFile);
+ $zip->extractTo($destination);
+ $zip->close();
+
+ return $finder;
+ }
+
+ public function getCacheDir(): string
+ {
+ return $this->createTmpDir('cache');
+ }
+
+ private function createTmpDir(string $type): string
+ {
+ $dir = sys_get_temp_dir().'/ux_toolkit/'.uniqid($type.'_', true);
+
+ if (!$this->filesystem->exists($dir)) {
+ $this->filesystem->mkdir($dir);
+ }
+
+ return $dir;
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/OfficialRepository.php b/src/Toolkit/src/ComponentRepository/OfficialRepository.php
new file mode 100644
index 00000000000..d1fb63aa459
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/OfficialRepository.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\ComponentRepository;
+
+use Symfony\Component\Finder\Finder;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+class OfficialRepository implements ComponentRepository
+{
+ public function fetch(RepositoryIdentity $repository): Finder
+ {
+ $finder = new Finder();
+ $finder->in(\sprintf(__DIR__.'/../../registry/%s', $repository->getPackage()));
+
+ return $finder;
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/RepositoryFactory.php b/src/Toolkit/src/ComponentRepository/RepositoryFactory.php
new file mode 100644
index 00000000000..b196278c267
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/RepositoryFactory.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\ComponentRepository;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+final class RepositoryFactory
+{
+ public function __construct(
+ private readonly OfficialRepository $officialRepository,
+ private readonly GithubRepository $githubRepository,
+ ) {
+ }
+
+ public function factory(RepositoryIdentity $repository): ComponentRepository
+ {
+ switch ($repository->getType()) {
+ case RepositorySources::EMBEDDED:
+ return $this->officialRepository;
+ case RepositorySources::GITHUB:
+ return $this->githubRepository;
+ }
+
+ throw new \InvalidArgumentException('Source is not supported for this component');
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/RepositoryIdentifier.php b/src/Toolkit/src/ComponentRepository/RepositoryIdentifier.php
new file mode 100644
index 00000000000..2480c846715
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/RepositoryIdentifier.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\ComponentRepository;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+final class RepositoryIdentifier
+{
+ public function identify(string $name): RepositoryIdentity
+ {
+ if (preg_match('!^\w+$!', $name)) {
+ // Official repository (with only the theme name)
+ return new RepositoryIdentity(
+ RepositorySources::EMBEDDED,
+ 'symfony',
+ 'default',
+ null
+ );
+ }
+
+ $name = preg_replace('!^(https://|http://)!', '', $name);
+ if (preg_match('!^github.com/(\w+)/(\w+)(@.+)?$!', $name, $matches)) {
+ // github.com/vendor/package@version
+ // github.com/vendor/package
+ // https://github.com/vendor/package
+ return new RepositoryIdentity(
+ RepositorySources::GITHUB,
+ $matches[1],
+ $matches[2],
+ trim($matches[3] ?? 'main', '@')
+ );
+ }
+
+ throw new \InvalidArgumentException('Source is not supported for this component');
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/RepositoryIdentity.php b/src/Toolkit/src/ComponentRepository/RepositoryIdentity.php
new file mode 100644
index 00000000000..0bcff7d18f7
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/RepositoryIdentity.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\ComponentRepository;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+final readonly class RepositoryIdentity
+{
+ public function __construct(
+ private int $type,
+ private string $vendor,
+ private ?string $package = null,
+ private ?string $version = 'main',
+ ) {
+ if (!\in_array($type, [RepositorySources::EMBEDDED, RepositorySources::GITHUB], true)) {
+ throw new \InvalidArgumentException('Only "official" and "github" types are supported for the moment.');
+ }
+ }
+
+ public function getVendor(): string
+ {
+ return $this->vendor;
+ }
+
+ public function getPackage(): ?string
+ {
+ return $this->package;
+ }
+
+ public function getVersion(): ?string
+ {
+ return $this->version;
+ }
+
+ public function getType(): int
+ {
+ return $this->type;
+ }
+}
diff --git a/src/Toolkit/src/ComponentRepository/RepositorySources.php b/src/Toolkit/src/ComponentRepository/RepositorySources.php
new file mode 100644
index 00000000000..8dd075bc05b
--- /dev/null
+++ b/src/Toolkit/src/ComponentRepository/RepositorySources.php
@@ -0,0 +1,23 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\ComponentRepository;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+enum RepositorySources: int
+{
+ public const int EMBEDDED = 1;
+ public const int GITHUB = 2;
+}
diff --git a/src/Toolkit/src/DependencyInjection/Configuration.php b/src/Toolkit/src/DependencyInjection/Configuration.php
new file mode 100644
index 00000000000..613aec99a2e
--- /dev/null
+++ b/src/Toolkit/src/DependencyInjection/Configuration.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\DependencyInjection;
+
+use Symfony\Component\Config\Definition\Builder\TreeBuilder;
+use Symfony\Component\Config\Definition\ConfigurationInterface;
+
+/**
+ * @author Jean-François Lépine
+ */
+class Configuration implements ConfigurationInterface
+{
+ public function getConfigTreeBuilder(): TreeBuilder
+ {
+ $treeBuilder = new TreeBuilder('ux_toolkit');
+
+ $treeBuilder->getRootNode()
+ ->children()
+ ->stringNode('theme')
+ ->defaultValue('default')
+ ->end()
+ ->stringNode('prefix')
+ ->defaultValue(null)
+ ->end()
+ ->end();
+
+ return $treeBuilder;
+ }
+}
diff --git a/src/Toolkit/src/DependencyInjection/ToolkitExtension.php b/src/Toolkit/src/DependencyInjection/ToolkitExtension.php
new file mode 100644
index 00000000000..0b6dce67082
--- /dev/null
+++ b/src/Toolkit/src/DependencyInjection/ToolkitExtension.php
@@ -0,0 +1,42 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\DependencyInjection;
+
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\HttpKernel\DependencyInjection\Extension;
+
+/**
+ * @author Jean-François Lépine
+ */
+class ToolkitExtension extends Extension
+{
+ public function getAlias(): string
+ {
+ return 'ux_toolkit';
+ }
+
+ public function getConfiguration(array $config, ContainerBuilder $container): Configuration
+ {
+ return new Configuration();
+ }
+
+ public function load(array $configs, ContainerBuilder $container): void
+ {
+ $configuration = $this->getConfiguration($configs, $container);
+ $config = $this->processConfiguration($configuration, $configs);
+
+ // Expose the prefix and theme configured as parameter (for the moment). It will be injected to
+ // the service responsible for rendering the components.
+ $container->setParameter('ux_toolkit.theme', $config['theme']);
+ $container->setParameter('ux_toolkit.prefix', $config['prefix']);
+ }
+}
diff --git a/src/Toolkit/src/Registry/DependenciesResolver.php b/src/Toolkit/src/Registry/DependenciesResolver.php
new file mode 100644
index 00000000000..32aae313914
--- /dev/null
+++ b/src/Toolkit/src/Registry/DependenciesResolver.php
@@ -0,0 +1,71 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Registry;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+class DependenciesResolver
+{
+ public function resolve(Registry $registry): array
+ {
+ $resolved = [];
+ $unresolved = [];
+
+ $concernedComponents = [];
+ foreach ($registry->all() as $item) {
+ if (RegistryItemType::Component !== $item->type) {
+ continue;
+ }
+
+ $concernedComponents[] = $item->name;
+ }
+
+ foreach ($concernedComponents as $itemName) {
+ [$resolved, $unresolved] = $this->resolveDependency($registry, $itemName, $resolved, $unresolved);
+ }
+
+ $sorted = [];
+ foreach ($resolved as $itemName) {
+ $sorted[] = $registry->get($itemName);
+ }
+
+ return $sorted;
+ }
+
+ private function resolveDependency(Registry $registry, string $itemName, array $resolved, array $unresolved)
+ {
+ $unresolved[] = $itemName;
+
+ foreach ($registry->get($itemName)->getDependencies() as $dep) {
+ if (!\in_array($dep, $resolved)) {
+ if (!\in_array($dep, $unresolved)) {
+ $unresolved[] = $dep;
+ [$resolved, $unresolved] = $this->resolveDependency($registry, $dep, $resolved, $unresolved);
+ } else {
+ throw new \RuntimeException("Circular dependency detected: $itemName -> $dep.");
+ }
+ }
+ }
+ if (!\in_array($itemName, $resolved)) {
+ $resolved[] = $itemName;
+ }
+
+ while (($index = array_search($itemName, $unresolved)) !== false) {
+ unset($unresolved[$index]);
+ }
+
+ return [$resolved, $unresolved];
+ }
+}
diff --git a/src/Toolkit/src/Registry/Registry.php b/src/Toolkit/src/Registry/Registry.php
new file mode 100644
index 00000000000..9e973c43be2
--- /dev/null
+++ b/src/Toolkit/src/Registry/Registry.php
@@ -0,0 +1,127 @@
+
+ *
+ * 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
+ */
+final class Registry
+{
+ /**
+ * @var RegistryItem[]
+ */
+ private array $items = [];
+
+ /**
+ * @var string[]
+ */
+ private array $licenses = [];
+
+ /**
+ * @var array array{name: string, email: string|null}[]
+ */
+ private array $authors = [];
+
+ /**
+ * @var string|null homepage
+ */
+ private ?string $homepage;
+
+ /**
+ * @var string|null name
+ */
+ private ?string $name;
+
+ public static function empty(): self
+ {
+ return new self();
+ }
+
+ public function add(RegistryItem $item): void
+ {
+ $this->items[] = $item;
+ }
+
+ /**
+ * @return RegistryItem[]
+ */
+ public function all(): array
+ {
+ return $this->items;
+ }
+
+ public function has(string $name): bool
+ {
+ return null !== $this->get($name);
+ }
+
+ public function get(string $name, RegistryItemType $type = RegistryItemType::Component): ?RegistryItem
+ {
+ foreach ($this->items as $item) {
+
+ if ($item->type !== $type) {
+ continue;
+ }
+
+ if ($item->name === $name) {
+ return $item;
+ }
+ }
+
+ return null;
+ }
+
+ public function addLicense(string $license): void
+ {
+ $this->licenses[] = $license;
+ }
+
+ public function addAuthor(string $name, ?string $email): void
+ {
+ $this->authors[] = [
+ 'name' => $name,
+ 'email' => $email,
+ ];
+ }
+
+ public function setName(string $name): void
+ {
+ $this->name = $name;
+ }
+
+ public function setHomepage(string $homepage): void
+ {
+ $this->homepage = $homepage;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getLicenses(): array
+ {
+ return $this->licenses;
+ }
+
+ public function getAuthors(): array
+ {
+ return $this->authors;
+ }
+
+ public function getHomepage(): ?string
+ {
+ return $this->homepage;
+ }
+}
diff --git a/src/Toolkit/src/Registry/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php
new file mode 100644
index 00000000000..014f33b5e42
--- /dev/null
+++ b/src/Toolkit/src/Registry/RegistryFactory.php
@@ -0,0 +1,72 @@
+
+ *
+ * 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\Finder\Finder;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @internal
+ */
+final class RegistryFactory
+{
+ public function __construct(
+ private readonly Filesystem $filesystem,
+ ) {
+ }
+
+ public function create(Finder $finder): Registry
+ {
+ $finderManifest = clone $finder;
+ $files = $finderManifest->files()->name('registry.json')->getIterator();
+ $files->rewind();
+ $manifestFile = $files->current();
+ if (!$manifestFile) {
+ throw new \RuntimeException('The manifest file is missing.');
+ }
+
+ $registry = Registry::empty();
+ $manifest = json_decode($manifestFile->getContents(), true);
+
+ foreach ($manifest['items'] ?? [] as $item) {
+ $filename = $item['manifest'];
+ $localFinder = clone $finder;
+ $files = iterator_to_array($localFinder->path($item['manifest']));
+
+ if (1 !== \count($files)) {
+ throw new \RuntimeException(\sprintf('The file "%s" declared in the manifest is missing.', $filename));
+ }
+ $file = reset($files);
+
+ if (!isset($item['fingerprint']) && isset($item['code'])) {
+ throw new \RuntimeException(\sprintf('The file "%s" declared in the manifest must have a fingerprint.', $filename));
+ }
+
+ $itemObject = RegistryItem::fromJsonFile($file);
+
+ if (isset($item['fingerprint'])) {
+ $hash = md5($itemObject->code);
+ if ($hash !== $item['fingerprint']) {
+ throw new \RuntimeException(\sprintf('The file "%s" declared in the manifest has an invalid hash.', $filename));
+ }
+ }
+
+ $registry->add($itemObject);
+ }
+
+ return $registry;
+ }
+}
diff --git a/src/Toolkit/src/Registry/RegistryItem.php b/src/Toolkit/src/Registry/RegistryItem.php
new file mode 100644
index 00000000000..bb73228af8f
--- /dev/null
+++ b/src/Toolkit/src/Registry/RegistryItem.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\UX\Toolkit\Registry;
+
+use Symfony\Component\Finder\SplFileInfo;
+
+/**
+ * @internal
+ */
+final readonly class RegistryItem
+{
+ /**
+ * https://regex101.com/r/8NcORd/1.
+ */
+ private const REGEX_RELATIVE_FILE = '#^(?Pdefault|new-york)/(?Pcomponent|example)s/(?P[A-Z][a-zA-Z]*)(?:/(?P[A-Z][a-zA-Z]*))?\.html\.twig$#';
+
+ public function __construct(
+ public string $name,
+ public RegistryItemType $type,
+ public string $theme,
+ public ?string $parentName,
+ public string $code,
+ private array $dependencies = [],
+ ) {
+ }
+
+ public static function fromJsonFile(SplFileInfo $file): self
+ {
+ $json = json_decode($file->getContents(), true);
+ if (null === $json) {
+ throw new \RuntimeException(\sprintf('The file "%s" is not a valid JSON file.', $file->getRelativePathname()));
+ }
+
+ if (!isset($json['name'], $json['type'], $json['theme'], $json['code'])) {
+ throw new \RuntimeException(\sprintf('The file "%s" must contain the following keys: "name", "type", "theme" and "code".', $file->getRelativePathname()));
+ }
+
+ return new self(
+ $json['name'],
+ RegistryItemType::from($json['type']),
+ $json['theme'],
+ $json['parentName'] ?? null,
+ $json['code'],
+ $json['dependencies'] ?? [],
+ );
+
+ // @todo: commented for the moment. Not sure to understand why we need regex here. To avoid to have json extension?
+ // if (!preg_match(self::REGEX_RELATIVE_FILE, $file->getRelativePathname(), $matches)) {
+ // throw new \InvalidArgumentException(\sprintf('Unable to parse file path "%s", it must match the following pattern: "//(/)?.html.twig"', $file->getRelativePathname()));
+ // }
+ //
+ // $theme = $matches['theme'];
+ // $type = RegistryItemType::from($matches['type']);
+ // $name = $matches['name'] ?? $matches['nameOrParentName'];
+ // $parentName = isset($matches['name']) ? $matches['nameOrParentName'] : null;
+ //
+ // return new self(
+ // $name,
+ // $type,
+ // $theme,
+ // $parentName,
+ // $file->getContents(),
+ // );
+ }
+
+ public static function fromTwigFile(SplFileInfo $file): self
+ {
+ if (!preg_match(self::REGEX_RELATIVE_FILE, $file->getRelativePathname(), $matches)) {
+ throw new \InvalidArgumentException(\sprintf('Unable to parse file path "%s", it must match the following pattern: "//(/)?.html.twig"', $file->getRelativePathname()));
+ }
+
+ $name = $matches['name'] ?? $matches['nameOrParentName'];
+ $parentName = $matches['nameOrParentName'] ?? null;
+ if ($name === $parentName) {
+ $parentName = null;
+ }
+ // @todo: we should improve the way we detect examples
+ $isExample = preg_match('#examples#', $file->getRelativePathname());
+ $type = $isExample ? RegistryItemType::Example : RegistryItemType::Component;
+ $theme = '';
+
+ return new self(
+ $name,
+ $type,
+ $theme,
+ $parentName,
+ $file->getContents(),
+ );
+ }
+
+ public function getDependencies(): array
+ {
+ $dependencies = $this->dependencies;
+ if (null !== $this->parentName) {
+ $dependencies[] = $this->parentName;
+ }
+
+ $dependencies = array_unique($dependencies);
+
+ return $dependencies;
+ }
+}
diff --git a/src/Toolkit/src/Registry/RegistryItemType.php b/src/Toolkit/src/Registry/RegistryItemType.php
new file mode 100644
index 00000000000..9a9692c49e7
--- /dev/null
+++ b/src/Toolkit/src/Registry/RegistryItemType.php
@@ -0,0 +1,10 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit;
+
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
+use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
+use Symfony\UX\Toolkit\Command\BuildRegistryCommand;
+use Symfony\UX\Toolkit\Command\DebugUxToolkitCommand;
+use Symfony\UX\Toolkit\Command\UxToolkitInstallCommand;
+use Symfony\UX\Toolkit\Compiler\RegistryCompiler;
+use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler;
+use Symfony\UX\Toolkit\ComponentRepository\CurrentTheme;
+use Symfony\UX\Toolkit\ComponentRepository\GithubRepository;
+use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryFactory;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentifier;
+use Symfony\UX\Toolkit\DependencyInjection\ToolkitExtension;
+use Symfony\UX\Toolkit\Registry\DependenciesResolver;
+use Symfony\UX\Toolkit\Registry\RegistryFactory;
+
+/**
+ * @author Jean-François Lépine
+ * @author Hugo Alliaume
+ */
+class UxToolkitBundle extends AbstractBundle
+{
+ public function getContainerExtension(): ?ExtensionInterface
+ {
+ return new ToolkitExtension();
+ }
+
+ public function build(ContainerBuilder $container): void
+ {
+ parent::build($container);
+
+ $container->autowire(OfficialRepository::class);
+ $container->autowire(GithubRepository::class);
+ $container->autowire(RepositoryFactory::class);
+ $container->autowire(RepositoryIdentifier::class);
+ $container->autowire(RegistryFactory::class);
+ $container->autowire(DependenciesResolver::class);
+ $container->autowire(RegistryCompiler::class);
+
+ $container->autowire(TwigComponentCompiler::class);
+ $container->getDefinition(TwigComponentCompiler::class)
+ ->setArguments([
+ '$prefix' => '%ux_toolkit.prefix%',
+ ]);
+
+ // Prepare commands
+ $this->addConsoleCommand($container, BuildRegistryCommand::class);
+ $this->addConsoleCommand($container, UxToolkitInstallCommand::class);
+ $this->addConsoleCommand($container, DebugUxToolkitCommand::class);
+
+ // Inject http client (if exists) to github repository
+ if ($container->has('http_client')) {
+ $container->getDefinition(GithubRepository::class)
+ ->setArgument('$httpClient', $container->get('http_client'));
+ }
+
+ // current theme
+ $container->autowire(CurrentTheme::class);
+ $container->getDefinition(CurrentTheme::class)
+ ->setArguments([
+ '$theme' => '%ux_toolkit.theme%',
+ ]);
+ }
+
+ /**
+ * @param ContainerBuilder $container
+ * @param string $classname
+ * @return void
+ */
+ public function addConsoleCommand(ContainerBuilder $container, string $classname): void
+ {
+ $container->autowire($classname);
+ $container
+ ->registerForAutoconfiguration($classname)
+ ->addTag('console.command');
+ $container
+ ->getDefinition($classname)
+ ->setPublic(true)
+ ->addTag('console.command');
+ }
+
+}
diff --git a/src/Toolkit/templates/default/components/Alert.html.twig b/src/Toolkit/templates/default/components/Alert.html.twig
new file mode 100644
index 00000000000..51248f49110
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Alert.html.twig
@@ -0,0 +1,6 @@
+
+ Dependency test
+ {% block content %}Alert{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Badge.html.twig b/src/Toolkit/templates/default/components/Badge.html.twig
new file mode 100644
index 00000000000..eab142dd5d1
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Badge.html.twig
@@ -0,0 +1,34 @@
+{%- props variant = 'default', outline = false -%}
+{%- set style = html_cva(
+ base: 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ },
+ outline: {
+ true: "text-foreground bg-white",
+ }
+ },
+ compoundVariants: [{
+ variant: ['default'],
+ outline: ['true'],
+ class: 'border-primary',
+ }, {
+ variant: ['secondary'],
+ outline: ['true'],
+ class: 'border-secondary',
+ }, {
+ variant: ['destructive'],
+ outline: ['true'],
+ class: 'border-destructive',
+ },]
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Button.html.twig b/src/Toolkit/templates/default/components/Button.html.twig
new file mode 100644
index 00000000000..ccbb6e9fc9b
--- /dev/null
+++ b/src/Toolkit/templates/default/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/src/Toolkit/templates/default/components/Card.html.twig b/src/Toolkit/templates/default/components/Card.html.twig
new file mode 100644
index 00000000000..d59ae429960
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Card.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'rounded-xl border bg-card text-card-foreground shadow',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Card/CardContent.html.twig b/src/Toolkit/templates/default/components/Card/CardContent.html.twig
new file mode 100644
index 00000000000..c54f24197d3
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Card/CardContent.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'p-6 pt-0',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Card/CardDescription.html.twig b/src/Toolkit/templates/default/components/Card/CardDescription.html.twig
new file mode 100644
index 00000000000..cdd70d9422a
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Card/CardDescription.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'text-sm text-muted-foreground',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Card/CardFooter.html.twig b/src/Toolkit/templates/default/components/Card/CardFooter.html.twig
new file mode 100644
index 00000000000..1325d35311b
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Card/CardFooter.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'flex items-center p-6 pt-0',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Card/CardHeader.html.twig b/src/Toolkit/templates/default/components/Card/CardHeader.html.twig
new file mode 100644
index 00000000000..fb96700289b
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Card/CardHeader.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'flex flex-col space-y-1.5 p-6',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Card/CardTitle.html.twig b/src/Toolkit/templates/default/components/Card/CardTitle.html.twig
new file mode 100644
index 00000000000..c6decf07ea4
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Card/CardTitle.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'font-semibold leading-none tracking-tight',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Grid.html.twig b/src/Toolkit/templates/default/components/Grid.html.twig
new file mode 100644
index 00000000000..cf457fec748
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Grid.html.twig
@@ -0,0 +1,89 @@
+{%- props
+ xs = 0,
+ sm = 0,
+ md = 0,
+ lg = 0,
+ xl = 0,
+ xsOffset = 0,
+ smOffset = 0,
+ mdOffset = 0,
+ lgOffset = 0,
+ xlOffset = 0,
+ align = '',
+ justify = '',
+ direction = '',
+-%}
+
+{% set xsSize = false %}{% if xs > 0 %}{% set xsSize = "true" %}{% endif %}
+{% set smSize = false %}{% if sm > 0 %}{% set smSize = "true" %}{% endif %}
+{% set mdSize = false %}{% if md > 0 %}{% set mdSize = "true" %}{% endif %}
+{% set lgSize = false %}{% if lg > 0 %}{% set lgSize = "true" %}{% endif %}
+{% set xlSize = false %}{% if xl > 0 %}{% set xlSize = "true" %}{% endif %}
+
+{% set xsOffsetSize = false %}{% if xsOffset > 0 %}{% set xsOffsetSize = "true" %}{% endif %}
+{% set smOffsetSize = false %}{% if smOffset > 0 %}{% set smOffsetSize = "true" %}{% endif %}
+{% set mdOffsetSize = false %}{% if mdOffset > 0 %}{% set mdOffsetSize = "true" %}{% endif %}
+{% set lgOffsetSize = false %}{% if lgOffset > 0 %}{% set lgOffsetSize = "true" %}{% endif %}
+{% set xlOffsetSize = false %}{% if xlOffset > 0 %}{% set xlOffsetSize = "true" %}{% endif %}
+
+{% set alignClass = '' %}
+{% if align == 'start' %}{% set alignClass = 'items-start' %}
+{% elseif align == 'center' %}{% set alignClass = 'items-center' %}
+{% elseif align == 'end' %}{% set alignClass = 'items-end' %}
+{% endif %}
+
+{% set justifyClass = '' %}
+{% if justify == 'start' %}{% set justifyClass = 'justify-start' %}
+{% elseif justify == 'center' %}{% set justifyClass = 'justify-center' %}
+{% elseif justify == 'end' %}{% set justifyClass = 'justify-end' %}
+{% elseif justify == 'between' %}{% set justifyClass = 'justify-between' %}
+{% elseif justify == 'around' %}{% set justifyClass = 'justify-around' %}
+{% elseif justify == 'evenly' %}{% set justifyClass = 'justify-evenly' %}
+{% endif %}
+
+{% set directionClass = '' %}
+{% if direction == 'row' %}{% set directionClass = 'flex-row' %}
+{% elseif direction == 'column' %}{% set directionClass = 'flex-col' %}
+{% endif %}
+
+{%- set style = html_cva(
+ base: 'grid',
+ variants: {
+ xsSize: {
+ "true": 'grid-cols-' ~ xs,
+ },
+ smSize: {
+ "true": 'sm:grid-cols-' ~ sm,
+ },
+ mdSize: {
+ "true": 'md:grid-cols-' ~ md,
+ },
+ lgSize: {
+ "true": 'lg:grid-cols-' ~ lg,
+ },
+ xlSize: {
+ "true": 'xl:grid-cols-' ~ xl,
+ },
+ xsOffsetSize: {
+ "true": 'col-start-' ~ (xsOffset + 1),
+ },
+ smOffsetSize: {
+ "true": 'sm:col-start-' ~ (smOffset + 1),
+ },
+ mdOffsetSize: {
+ "true": 'md:col-start-' ~ (mdOffset + 1),
+ },
+ lgOffsetSize: {
+ "true": 'lg:col-start-' ~ (lgOffset + 1),
+ },
+ xlOffsetSize: {
+ "true": 'xl:col-start-' ~ (xlOffset + 1),
+ },
+ },
+) -%}
+
+ {% block content %}{% endblock %}
+
\ No newline at end of file
diff --git a/src/Toolkit/templates/default/components/Grid/Col.html.twig b/src/Toolkit/templates/default/components/Grid/Col.html.twig
new file mode 100644
index 00000000000..0a263303fba
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Grid/Col.html.twig
@@ -0,0 +1,77 @@
+{%- props
+ xs = 0,
+ sm = 0,
+ md = 0,
+ lg = 0,
+ xl = 0,
+ xsOffset = 0,
+ smOffset = 0,
+ mdOffset = 0,
+ lgOffset = 0,
+ xlOffset = 0,
+ first = false,
+ last = false,
+-%}
+
+{% set xsCols = false %}{% if xs > 0 %}{% set xsCols = "true" %}{% endif %}
+{% set smCols = false %}{% if sm > 0 %}{% set smCols = "true" %}{% endif %}
+{% set mdCols = false %}{% if md > 0 %}{% set mdCols = "true" %}{% endif %}
+{% set lgCols = false %}{% if lg > 0 %}{% set lgCols = "true" %}{% endif %}
+{% set xlCols = false %}{% if xl > 0 %}{% set xlCols = "true" %}{% endif %}
+
+{% set xsOffsetCols = false %}{% if xsOffset > 0 %}{% set xsOffsetCols = "true" %}{% endif %}
+{% set smOffsetCols = false %}{% if smOffset > 0 %}{% set smOffsetCols = "true" %}{% endif %}
+{% set mdOffsetCols = false %}{% if mdOffset > 0 %}{% set mdOffsetCols = "true" %}{% endif %}
+{% set lgOffsetCols = false %}{% if lgOffset > 0 %}{% set lgOffsetCols = "true" %}{% endif %}
+{% set xlOffsetCols = false %}{% if xlOffset > 0 %}{% set xlOffsetCols = "true" %}{% endif %}
+
+{% set firstClass = false %}{% if first %}{% set firstClass = "true" %}{% endif %}
+{% set lastClass = false %}{% if last %}{% set lastClass = "true" %}{% endif %}
+
+{%- set style = html_cva(
+ base: '',
+ variants: {
+ xsCols: {
+ "true": 'col-span-' ~ xs,
+ },
+ smCols: {
+ "true": 'sm:col-span-' ~ sm,
+ },
+ mdCols: {
+ "true": 'md:col-span-' ~ md,
+ },
+ lgCols: {
+ "true": 'lg:col-span-' ~ lg,
+ },
+ xlCols: {
+ "true": 'xl:col-span-' ~ xl,
+ },
+ xsOffsetCols: {
+ "true": 'col-start-' ~ (xsOffset + 1),
+ },
+ smOffsetCols: {
+ "true": 'sm:col-start-' ~ (smOffset + 1),
+ },
+ mdOffsetCols: {
+ "true": 'md:col-start-' ~ (mdOffset + 1),
+ },
+ lgOffsetCols: {
+ "true": 'lg:col-start-' ~ (lgOffset + 1),
+ },
+ xlOffsetCols: {
+ "true": 'xl:col-start-' ~ (xlOffset + 1),
+ },
+ firstClass: {
+ "true": 'order-first',
+ },
+ lastClass: {
+ "true": 'order-last',
+ },
+ },
+) -%}
+
+ {% block content %}{% endblock %}
+
\ No newline at end of file
diff --git a/src/Toolkit/templates/default/components/Grid/Row.html.twig b/src/Toolkit/templates/default/components/Grid/Row.html.twig
new file mode 100644
index 00000000000..9ebef53f7f5
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Grid/Row.html.twig
@@ -0,0 +1,60 @@
+{%- props
+ start = 'auto',
+ center = false,
+ end = false,
+ top = false,
+ middle = false,
+ bottom = false,
+ around = false,
+ between = false,
+ reverse = false,
+-%}
+
+{% set startClass = false %}{% if start != 'auto' %}{% set startClass = "true" %}{% endif %}
+{% set centerClass = false %}{% if center %}{% set centerClass = "true" %}{% endif %}
+{% set endClass = false %}{% if end %}{% set endClass = "true" %}{% endif %}
+{% set topClass = false %}{% if top %}{% set topClass = "true" %}{% endif %}
+{% set middleClass = false %}{% if middle %}{% set middleClass = "true" %}{% endif %}
+{% set bottomClass = false %}{% if bottom %}{% set bottomClass = "true" %}{% endif %}
+{% set aroundClass = false %}{% if around %}{% set aroundClass = "true" %}{% endif %}
+{% set betweenClass = false %}{% if between %}{% set betweenClass = "true" %}{% endif %}
+{% set reverseClass = false %}{% if reverse %}{% set reverseClass = "true" %}{% endif %}
+
+{%- set style = html_cva(
+ base: 'flex flex-wrap',
+ variants: {
+ startClass: {
+ "true": 'justify-start',
+ },
+ centerClass: {
+ "true": 'justify-center',
+ },
+ endClass: {
+ "true": 'justify-end',
+ },
+ topClass: {
+ "true": 'items-start',
+ },
+ middleClass: {
+ "true": 'items-center',
+ },
+ bottomClass: {
+ "true": 'items-end',
+ },
+ aroundClass: {
+ "true": 'justify-around',
+ },
+ betweenClass: {
+ "true": 'justify-between',
+ },
+ reverseClass: {
+ "true": 'flex-row-reverse',
+ },
+ },
+) -%}
+
+ {% block content %}{% endblock %}
+
\ No newline at end of file
diff --git a/src/Toolkit/templates/default/components/Navbar.html.twig b/src/Toolkit/templates/default/components/Navbar.html.twig
new file mode 100644
index 00000000000..60a10b244d2
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Navbar.html.twig
@@ -0,0 +1,5 @@
+
diff --git a/src/Toolkit/templates/default/components/Table.html.twig b/src/Toolkit/templates/default/components/Table.html.twig
new file mode 100644
index 00000000000..f0bf2ab38b5
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'relative w-full overflow-auto',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Table/TableBody.html.twig b/src/Toolkit/templates/default/components/Table/TableBody.html.twig
new file mode 100644
index 00000000000..3ed0f02312b
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableBody.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: '[&_tr:last-child]:border-0',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Table/TableCaption.html.twig b/src/Toolkit/templates/default/components/Table/TableCaption.html.twig
new file mode 100644
index 00000000000..def36aac655
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableCaption.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'text-muted-foreground mt-4 text-sm',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Table/TableCell.html.twig b/src/Toolkit/templates/default/components/Table/TableCell.html.twig
new file mode 100644
index 00000000000..db584a4477e
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableCell.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+ |
diff --git a/src/Toolkit/templates/default/components/Table/TableFooter.html.twig b/src/Toolkit/templates/default/components/Table/TableFooter.html.twig
new file mode 100644
index 00000000000..cbd36d1e6e9
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableFooter.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Table/TableHead.html.twig b/src/Toolkit/templates/default/components/Table/TableHead.html.twig
new file mode 100644
index 00000000000..73e38418a6c
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableHead.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+ |
diff --git a/src/Toolkit/templates/default/components/Table/TableHeader.html.twig b/src/Toolkit/templates/default/components/Table/TableHeader.html.twig
new file mode 100644
index 00000000000..e5ae84ab9dd
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableHeader.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: '[&_tr]:border-b',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/components/Table/TableRow.html.twig b/src/Toolkit/templates/default/components/Table/TableRow.html.twig
new file mode 100644
index 00000000000..d3de2112b82
--- /dev/null
+++ b/src/Toolkit/templates/default/components/Table/TableRow.html.twig
@@ -0,0 +1,13 @@
+{%- props -%}
+{%- set style = html_cva(
+ base: 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
+ variants: {},
+ compoundVariants: []
+) -%}
+
+
+ {% block content %}{% endblock %}
+
diff --git a/src/Toolkit/templates/default/examples/Badge.html.twig b/src/Toolkit/templates/default/examples/Badge.html.twig
new file mode 100644
index 00000000000..94afe17dba9
--- /dev/null
+++ b/src/Toolkit/templates/default/examples/Badge.html.twig
@@ -0,0 +1 @@
+Badge
diff --git a/src/Toolkit/templates/default/examples/BadgeOutline.html.twig b/src/Toolkit/templates/default/examples/BadgeOutline.html.twig
new file mode 100644
index 00000000000..3f92f232c29
--- /dev/null
+++ b/src/Toolkit/templates/default/examples/BadgeOutline.html.twig
@@ -0,0 +1,3 @@
+Badge
+Badge
+Badge
diff --git a/src/Toolkit/templates/default/examples/Button.html.twig b/src/Toolkit/templates/default/examples/Button.html.twig
new file mode 100644
index 00000000000..2b923e00217
--- /dev/null
+++ b/src/Toolkit/templates/default/examples/Button.html.twig
@@ -0,0 +1 @@
+Click me
diff --git a/src/Toolkit/templates/default/examples/Grid.html.twig b/src/Toolkit/templates/default/examples/Grid.html.twig
new file mode 100644
index 00000000000..41eec7726e4
--- /dev/null
+++ b/src/Toolkit/templates/default/examples/Grid.html.twig
@@ -0,0 +1,8 @@
+
+ col
+ col
+ col
+ large column
+ col
+ col
+
\ No newline at end of file
diff --git a/src/Toolkit/templates/default/examples/Table.html.twig b/src/Toolkit/templates/default/examples/Table.html.twig
new file mode 100644
index 00000000000..26e90104f3f
--- /dev/null
+++ b/src/Toolkit/templates/default/examples/Table.html.twig
@@ -0,0 +1,19 @@
+
+ A list of your recent invoices.
+
+
+ Invoice
+ Status
+ Method
+ Amount
+
+
+
+
+ INV001
+ Paid
+ Credit Card
+ $250.00
+
+
+
\ No newline at end of file
diff --git a/src/Toolkit/tests/Command/BuildRegistryCommandTest.php b/src/Toolkit/tests/Command/BuildRegistryCommandTest.php
new file mode 100644
index 00000000000..0acd4ea9bf7
--- /dev/null
+++ b/src/Toolkit/tests/Command/BuildRegistryCommandTest.php
@@ -0,0 +1,74 @@
+
+ *
+ * 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 PHPUnit\Framework\TestCase;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Zenstruck\Console\Test\InteractsWithConsole;
+
+/**
+ * @author Jean-François Lépine
+ */
+class BuildRegistryCommandTest extends KernelTestCase
+{
+ use InteractsWithConsole;
+
+ public function testShouldBeAbleToBuildRegistry(): void
+ {
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid();
+ mkdir($destination);
+
+ $this->bootKernel();
+ $this->consoleCommand('ux:toolkit:build-registry --destination='.$destination)
+ ->execute()
+ ->assertSuccessful()
+ ->assertOutputContains('default/components/Alert.html.twig')
+ ->assertOutputContains('default/components/Table.html.twig')
+ ->assertOutputContains('default/components/Table/TableHeader.html.twig')
+ ;
+
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'registry.json');
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'components/Alert.json');
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'components/Table.json');
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'components/Table/TableRow.json');
+
+ $row = json_decode(file_get_contents($destination.\DIRECTORY_SEPARATOR.'components/Table/TableRow.json'), true);
+ $this->assertSame('TableRow', $row['name']);
+ $this->assertNotEmpty($row['code']);
+ $this->assertSame(md5($row['code']), $row['fingerprint']);
+ }
+
+ public function testShouldBeAbleToBuildRegistryWithMetadata(): void
+ {
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid();
+ mkdir($destination);
+
+ $this->bootKernel();
+ $this->consoleCommand("ux:toolkit:build-registry --licenses=MIT --licenses=CECIL-B --destination=$destination SymfonyUX 'https://www.symfony.com'")
+ ->execute()
+ ->assertSuccessful()
+ ->assertOutputContains('default/components/Alert.html.twig')
+ ->assertOutputContains('default/components/Table.html.twig')
+ ->assertOutputContains('default/components/Table/TableHeader.html.twig')
+ ;
+
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'registry.json');
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'components/Alert.json');
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'components/Table.json');
+ $this->assertFileExists($destination.\DIRECTORY_SEPARATOR.'components/Table/TableRow.json');
+
+ $json = json_decode(file_get_contents($destination.\DIRECTORY_SEPARATOR.'registry.json'), true);
+ $this->assertSame(['MIT', 'CECIL-B'], $json['licenses']);
+ $this->assertSame('https://www.symfony.com', $json['homepage']);
+ $this->assertSame('SymfonyUX', $json['name']);
+ }
+}
diff --git a/src/Toolkit/tests/Command/DebugUxToolkitCommandTest.php b/src/Toolkit/tests/Command/DebugUxToolkitCommandTest.php
new file mode 100644
index 00000000000..b47e2bad810
--- /dev/null
+++ b/src/Toolkit/tests/Command/DebugUxToolkitCommandTest.php
@@ -0,0 +1,40 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Tests\Command;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Zenstruck\Console\Test\InteractsWithConsole;
+
+/**
+ * @author Jean-François Lépine
+ */
+class DebugUxToolkitCommandTest extends KernelTestCase
+{
+ use InteractsWithConsole;
+
+ public function testShouldBeAbleToListComponents(): void
+ {
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid();
+ mkdir($destination);
+
+ $this->bootKernel();
+ $this->consoleCommand('debug:ux:toolkit')
+ ->execute()
+ ->assertSuccessful()
+ ->assertOutputContains('Current theme:')
+ ->assertOutputContains('Available components:')
+ ->assertOutputContains('Badge')
+ ->assertOutputContains('Button')
+ ;
+ }
+}
diff --git a/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php b/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php
new file mode 100644
index 00000000000..7df39b735a3
--- /dev/null
+++ b/src/Toolkit/tests/Command/UxToolkitInstallCommandTest.php
@@ -0,0 +1,74 @@
+
+ *
+ * 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;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @group wip
+ */
+class UxToolkitInstallCommandTest extends KernelTestCase
+{
+ use InteractsWithConsole;
+
+ public function testShouldAbleToCreateTheBadgeComponent(): void
+ {
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid();
+ mkdir($destination);
+
+ $this->bootKernel();
+ $this->consoleCommand('ux:toolkit:install badge --destination='.$destination)
+ ->execute()
+ ->assertSuccessful()
+ ->assertOutputContains('component "Badge" has been installed');
+
+ // A file should be created
+ $expectedFile = $destination.\DIRECTORY_SEPARATOR.'Badge.html.twig';
+ $this->assertFileExists($expectedFile);
+
+ // The content of the file should be the same as the content of the Badge component
+ $expectedContent = file_get_contents(__DIR__.'/../../templates/default/components/Badge.html.twig');
+ $actualContent = file_get_contents($expectedFile);
+ $this->assertEquals($expectedContent, $actualContent);
+ }
+
+ public function testShouldFailWhenComponentDoesNotExist(): void
+ {
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid();
+ mkdir($destination);
+
+ $this->bootKernel();
+ $this->consoleCommand('ux:toolkit:install 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 badge --destination='.$destination)
+ ->execute()
+ ->assertSuccessful();
+
+ $this->consoleCommand('ux:toolkit:install badge --destination='.$destination)
+ ->execute()
+ ->assertFaulty()
+ ->assertOutputContains('The component "Badge" already exists.');
+ }
+}
diff --git a/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.php b/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.php
new file mode 100644
index 00000000000..edb783e1aaa
--- /dev/null
+++ b/src/Toolkit/tests/Compiler/TwigComponentCompilerTest.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\UX\Toolkit\Tests\Compiler;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\Toolkit\Compiler\Exception\TwigComponentAlreadyExist;
+use Symfony\UX\Toolkit\Compiler\TwigComponentCompiler;
+use Symfony\UX\Toolkit\Registry\DependenciesResolver;
+use Symfony\UX\Toolkit\Registry\Registry;
+use Symfony\UX\Toolkit\Registry\RegistryItem;
+use Symfony\UX\Toolkit\Registry\RegistryItemType;
+
+/**
+ * @author Jean-François Lépine
+ */
+class TwigComponentCompilerTest extends TestCase
+{
+ public function testItShouldCompileComponentToFile(): void
+ {
+ $compiler = new TwigComponentCompiler('Acme', new DependenciesResolver());
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('component_');
+
+ $registry = new Registry();
+ $item = new RegistryItem(
+ 'Badge',
+ RegistryItemType::Component,
+ 'default',
+ null,
+ ''
+ );
+ $registry->add($item);
+
+ $compiler->compile($registry, $item, $destination);
+
+ $this->assertFileExists($destination);
+ $this->assertFileExists($destination.'/Acme/Badge.html.twig');
+
+ $content = file_get_contents($destination.'/Acme/Badge.html.twig');
+ $this->assertStringContainsString('', $content);
+ }
+
+ public function testShouldThrowExceptionIfFileAlreadyExist(): void
+ {
+ $compiler = new TwigComponentCompiler('Acme', new DependenciesResolver());
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('component_');
+
+ $registry = new Registry();
+ $item = new RegistryItem(
+ 'Badge',
+ RegistryItemType::Component,
+ 'default',
+ null,
+ ''
+ );
+ $registry->add($item);
+
+ $compiler->compile($registry, $item, $destination);
+
+ $this->expectException(TwigComponentAlreadyExist::class);
+ $compiler->compile($registry, $item, $destination);
+ }
+
+ public function testDependenciesAreAlsoCompiled(): void
+ {
+ $compiler = new TwigComponentCompiler('Acme', new DependenciesResolver());
+ $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('component_');
+
+ $registry = new Registry();
+ $registry->add(
+ new RegistryItem(
+ 'Badge',
+ RegistryItemType::Component,
+ 'default',
+ null,
+ ''
+ )
+ );
+ $registry->add(
+ new RegistryItem(
+ 'Table',
+ RegistryItemType::Component,
+ 'default',
+ null,
+ ''
+ )
+ );
+ $registry->add(
+ new RegistryItem(
+ 'TableRow',
+ RegistryItemType::Component,
+ 'default',
+ 'Table',
+ 'foo
'
+ )
+ );
+
+ $compiler->compile($registry, $registry->get('TableRow'), $destination);
+
+ $this->assertFileExists($destination);
+ $this->assertFileExists($destination.'/Acme/Table.html.twig');
+ $this->assertFileExists($destination.'/Acme/TableRow.html.twig');
+ $this->assertFileDoesNotExist($destination.'/Acme/Badge.html.twig');
+ }
+}
diff --git a/src/Toolkit/tests/Component/BadgeTest.php b/src/Toolkit/tests/Component/BadgeTest.php
new file mode 100644
index 00000000000..ffb2938a823
--- /dev/null
+++ b/src/Toolkit/tests/Component/BadgeTest.php
@@ -0,0 +1,41 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Tests\Component;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Twig\Environment;
+use Twig\Extra\Html\HtmlExtension;
+
+/**
+ * @author Jean-François Lépine
+ */
+class BadgeTest extends KernelTestCase
+{
+ public function testDefaultRenderingIsPossible(): void
+ {
+ $this->bootKernel();
+
+ $html = <<Demo
+my badge
+EOT;
+ /** @var Environment $twig */
+ $twig = static::getContainer()->get('twig');
+ $twig->addExtension(new HtmlExtension());
+
+ $template = $twig->createTemplate($html);
+ $output = $template->render([]);
+
+ $this->assertStringContainsString('class="inline-flex items-center ', $output);
+ $this->assertStringContainsString('my badge', $output);
+ }
+}
diff --git a/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php b/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php
new file mode 100644
index 00000000000..f412a396123
--- /dev/null
+++ b/src/Toolkit/tests/ComponentRepository/GithubRepositoryTest.php
@@ -0,0 +1,104 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Tests\ComponentRepository;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\UX\Toolkit\ComponentRepository\GithubRepository;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity;
+use Symfony\UX\Toolkit\ComponentRepository\RepositorySources;
+
+/**
+ * @author Jean-François Lépine
+ */
+class GithubRepositoryTest extends TestCase
+{
+ public function testGithubRepositoryUseClientAndTryToDownloadRemoteFile(): void
+ {
+ // Create a zip file with a pseudo manifest.json file
+ $manifest = '{"name:""Haleck45/ux-toolkit","version":"1.0.0"}';
+ $workdir = sys_get_temp_dir().'/ux-toolkit';
+ $filesystem = new Filesystem();
+ $filesystem->mkdir($workdir);
+ $filesystem->dumpFile($workdir.'/manifest.json', $manifest);
+ $filesystem->dumpFile($workdir.'/README.md', 'My readme content');
+
+ $zip = new \ZipArchive();
+ $zip->open($workdir.'/ux-toolkit-1.0.0.zip', \ZipArchive::CREATE);
+ $zip->addFile($workdir.'/manifest.json', 'manifest.json');
+ $zip->addFile($workdir.'/README.md', 'README.md');
+ $zip->close();
+
+ // Create a mock http client that will return the zip file
+ $client = new MockHttpClient();
+ $client->setResponseFactory(fn () => new MockResponse(
+ file_get_contents($workdir.'/ux-toolkit-1.0.0.zip'),
+ [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'content-type' => 'application/zip',
+ ],
+ ]
+ ));
+
+ $filesystem = new Filesystem();
+ $repository = new GithubRepository($filesystem, $client);
+
+ $component = new RepositoryIdentity(RepositorySources::GITHUB, 'Halleck45', 'ux-toolkit', '1.0.0');
+ $finder = $repository->fetch($component);
+
+ // the manifest file should be extracted
+ $manifestFile = $finder->files()->path('manifest.json')->count();
+ $this->assertSame(1, $manifestFile);
+ }
+
+ public function testGithubRepositorybUTwITHiNVALIDhEADERS(): void
+ {
+ // Create a zip file with a pseudo manifest.json file
+ $manifest = '{"name:""Haleck45/ux-toolkit","version":"1.0.0"}';
+ $workdir = sys_get_temp_dir().'/ux-toolkit';
+ $filesystem = new Filesystem();
+ $filesystem->mkdir($workdir);
+ $filesystem->dumpFile($workdir.'/manifest.json', $manifest);
+ $filesystem->dumpFile($workdir.'/README.md', 'My readme content');
+
+ $zip = new \ZipArchive();
+ $zip->open($workdir.'/ux-toolkit-1.0.0.zip', \ZipArchive::CREATE);
+ $zip->addFile($workdir.'/manifest.json', 'manifest.json');
+ $zip->addFile($workdir.'/README.md', 'README.md');
+ $zip->close();
+
+ // Create a mock http client that will return the zip file
+ $client = new MockHttpClient();
+ $client->setResponseFactory(fn () => new MockResponse(
+ file_get_contents($workdir.'/ux-toolkit-1.0.0.zip'),
+ [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'content-type' => 'application/json',
+ ],
+ ]
+ ));
+
+ $filesystem = new Filesystem();
+ $repository = new GithubRepository($filesystem, $client);
+
+ $component = new RepositoryIdentity(RepositorySources::GITHUB, 'Halleck45', 'ux-toolkit', '1.0.0');
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('The file from "http://github.com/Halleck45/ux-toolkit/archive/1.0.0.zip" is not a valid zip file.');
+
+ $repository->fetch($component);
+ }
+}
diff --git a/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php b/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php
new file mode 100644
index 00000000000..7eefbf8bb76
--- /dev/null
+++ b/src/Toolkit/tests/ComponentRepository/OfficialRepositoryTest.php
@@ -0,0 +1,44 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Tests\ComponentRepository;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity;
+use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository;
+use Symfony\UX\Toolkit\ComponentRepository\RepositorySources;
+
+/**
+ * @author Jean-François Lépine
+ */
+class OfficialRepositoryTest extends TestCase
+{
+ public function testOfficialRepositoryGetContentOfExistentComponent(): void
+ {
+ $repository = new OfficialRepository();
+ $identity = new RepositoryIdentity(RepositorySources::EMBEDDED, 'symfony', 'default');
+
+ $finder = $repository->fetch($identity);
+
+ $exists = $finder->files()->path('registry.json')->count();
+ $this->assertEquals(1, $exists);
+ }
+
+ public function testOfficialRepositoryFailWhenComponentDoesNotExist(): void
+ {
+ $repository = new OfficialRepository();
+ $identity = new RepositoryIdentity(RepositorySources::EMBEDDED, 'symfony', 'unexistent');
+
+ $this->expectException(DirectoryNotFoundException::class);
+ $finder = $repository->fetch($identity);
+ }
+}
diff --git a/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php b/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php
new file mode 100644
index 00000000000..d10c9b9c342
--- /dev/null
+++ b/src/Toolkit/tests/ComponentRepository/RepositoryFactoryTest.php
@@ -0,0 +1,59 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Toolkit\Tests\ComponentRepository;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\Toolkit\ComponentRepository\GithubRepository;
+use Symfony\UX\Toolkit\ComponentRepository\OfficialRepository;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryFactory;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity;
+use Symfony\UX\Toolkit\ComponentRepository\RepositorySources;
+
+/**
+ * @author Jean-François Lépine
+ */
+class RepositoryFactoryTest extends KernelTestCase
+{
+ /**
+ * @dataProvider providesSources
+ */
+ public function testItShouldFactoryRepositoryAccordingToItsName(
+ int $type,
+ ?string $expectedInstance,
+ bool $shouldThrowException = false,
+ ): void {
+ $this->bootKernel();
+ $factory = static::getContainer()->get(RepositoryFactory::class);
+
+ if ($shouldThrowException) {
+ $this->expectException(\InvalidArgumentException::class);
+ }
+
+ $result = $factory->factory(new RepositoryIdentity($type, 'myvendor', 'mypackage'));
+
+ if ($shouldThrowException) {
+ return;
+ }
+
+ $this->assertInstanceOf($expectedInstance, $result);
+ }
+
+ public static function providesSources(): array
+ {
+ return [
+ [RepositorySources::EMBEDDED, OfficialRepository::class, false],
+ [RepositorySources::GITHUB, GithubRepository::class, false],
+ [99, null, true],
+ ];
+ }
+}
diff --git a/src/Toolkit/tests/ComponentRepository/RepositoryIdentifierTest.php b/src/Toolkit/tests/ComponentRepository/RepositoryIdentifierTest.php
new file mode 100644
index 00000000000..f40a04ba8d5
--- /dev/null
+++ b/src/Toolkit/tests/ComponentRepository/RepositoryIdentifierTest.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\ComponentRepository;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentifier;
+use Symfony\UX\Toolkit\ComponentRepository\RepositoryIdentity;
+use Symfony\UX\Toolkit\ComponentRepository\RepositorySources;
+
+/**
+ * @author Jean-François Lépine
+ */
+class RepositoryIdentifierTest extends TestCase
+{
+ public function testItShouldIdentifyOfficialComponent(): void
+ {
+ $identifier = new RepositoryIdentifier();
+ $identity = $identifier->identify('default');
+
+ $this->assertInstanceOf(RepositoryIdentity::class, $identity);
+ $this->assertEquals(RepositorySources::EMBEDDED, $identity->getType());
+ $this->assertEquals('symfony', $identity->getVendor());
+ }
+
+ public function testItShouldIdentifyGithubComponent(): void
+ {
+ $identifier = new RepositoryIdentifier();
+ $identity = $identifier->identify('https://github.com/Halleck45/uikit');
+
+ $this->assertEquals(RepositorySources::GITHUB, $identity->getType());
+ $this->assertEquals('Halleck45', $identity->getVendor());
+ $this->assertEquals('uikit', $identity->getPackage());
+ }
+
+ public function testItShouldIdentifiyGithubComponentEventWithoutScheme(): void
+ {
+ $identifier = new RepositoryIdentifier();
+ $identity = $identifier->identify('github.com/Halleck45/uikit');
+
+ $this->assertEquals(RepositorySources::GITHUB, $identity->getType());
+ $this->assertEquals('Halleck45', $identity->getVendor());
+ $this->assertEquals('uikit', $identity->getPackage());
+ $this->assertEquals('main', $identity->getVersion());
+ }
+
+ public function testItShouldIdentifyGithubComponentWithVersion(): void
+ {
+ $identifier = new RepositoryIdentifier();
+ $identity = $identifier->identify('github.com/Halleck45/uikit@v1.0.0');
+
+ $this->assertEquals(RepositorySources::GITHUB, $identity->getType());
+ $this->assertEquals('Halleck45', $identity->getVendor());
+ $this->assertEquals('uikit', $identity->getPackage());
+ $this->assertEquals('v1.0.0', $identity->getVersion());
+ }
+}
diff --git a/src/Toolkit/tests/DependencyInjection/ToolkitExtensionTest.php b/src/Toolkit/tests/DependencyInjection/ToolkitExtensionTest.php
new file mode 100644
index 00000000000..8ea58bc7883
--- /dev/null
+++ b/src/Toolkit/tests/DependencyInjection/ToolkitExtensionTest.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\Tests\DependencyInjection;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\UX\Toolkit\DependencyInjection\ToolkitExtension;
+
+/**
+ * @author Jean-François Lépine
+ */
+class ToolkitExtensionTest extends TestCase
+{
+ public function testGetAlias(): void
+ {
+ $extension = new ToolkitExtension();
+ $this->assertEquals('ux_toolkit', $extension->getAlias());
+ }
+
+ public function testLoadInjectUsefulParameters(): void
+ {
+ $configs = [
+ 'prefix' => 'Acme',
+ 'theme' => 'default',
+ ];
+
+ $container = new ContainerBuilder();
+ $extension = new ToolkitExtension();
+ $extension->load([$configs], $container);
+
+ $this->assertTrue($container->hasParameter('ux_toolkit.prefix'));
+ $this->assertEquals('Acme', $container->getParameter('ux_toolkit.prefix'));
+
+ $this->assertTrue($container->hasParameter('ux_toolkit.theme'));
+ $this->assertEquals('default', $container->getParameter('ux_toolkit.theme'));
+ }
+}
diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php
new file mode 100644
index 00000000000..ebfaa6a05c1
--- /dev/null
+++ b/src/Toolkit/tests/Fixtures/Kernel.php
@@ -0,0 +1,60 @@
+
+ *
+ * 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;
+use TalesFromADev\Twig\Extra\Tailwind\Bridge\Symfony\Bundle\TalesFromADevTwigExtraTailwindBundle;
+
+/**
+ * @author Jean-François Lépine
+ */
+final class Kernel extends BaseKernel
+{
+ use MicroKernelTrait;
+
+ public function registerBundles(): iterable
+ {
+ return [
+ new FrameworkBundle(),
+ new TwigBundle(),
+ new TwigComponentBundle(),
+ new UxToolkitBundle(),
+ new TalesFromADevTwigExtraTailwindBundle(),
+ ];
+ }
+
+ protected function configureContainer(ContainerConfigurator $containerConfigurator): void
+ {
+ $config = [
+ 'secret' => 'SECRET',
+ 'test' => true,
+ ];
+
+ $containerConfigurator->extension('framework', $config);
+ $containerConfigurator->extension('twig', [
+ 'default_path' => __DIR__.'/../../templates/default',
+ ]);
+
+ $config = [
+ 'anonymous_template_directory' => 'components/',
+ 'defaults' => [],
+ ];
+
+ $containerConfigurator->extension('twig_component', $config);
+ }
+}
diff --git a/src/Toolkit/tests/Registry/DependenciesResolverTest.php b/src/Toolkit/tests/Registry/DependenciesResolverTest.php
new file mode 100644
index 00000000000..8bee4ad31bd
--- /dev/null
+++ b/src/Toolkit/tests/Registry/DependenciesResolverTest.php
@@ -0,0 +1,129 @@
+
+ *
+ * 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 PHPUnit\Framework\TestCase;
+use Symfony\UX\Toolkit\Registry\DependenciesResolver;
+use Symfony\UX\Toolkit\Registry\Registry;
+use Symfony\UX\Toolkit\Registry\RegistryItem;
+use Symfony\UX\Toolkit\Registry\RegistryItemType;
+
+/**
+ * @author Jean-François Lépine
+ *
+ * @group wip
+ */
+class DependenciesResolverTest extends TestCase
+{
+ public function testItShouldResolveDependenciesOrder(): void
+ {
+ $registry = new Registry();
+
+ $registry->add(
+ new RegistryItem(
+ 'cell',
+ RegistryItemType::Component,
+ 'default',
+ 'row',
+ ''
+ )
+ );
+
+ $registry->add(
+ new RegistryItem(
+ 'table',
+ RegistryItemType::Component,
+ 'default',
+ null,
+ ''
+ )
+ );
+
+ $registry->add(
+ new RegistryItem(
+ 'row',
+ RegistryItemType::Component,
+ 'default',
+ 'table',
+ ''
+ )
+ );
+
+ $registry->add(
+ new RegistryItem(
+ 'button',
+ RegistryItemType::Component,
+ 'default',
+ null,
+ ''
+ )
+ );
+
+ $registry->add(
+ new RegistryItem(
+ 'icon',
+ RegistryItemType::Component,
+ 'default',
+ 'button',
+ ''
+ )
+ );
+
+ $resolver = new DependenciesResolver();
+ $resolved = $resolver->resolve($registry);
+ $this->assertEquals('table', $resolved[0]->name);
+ $this->assertEquals('row', $resolved[1]->name);
+ $this->assertEquals('cell', $resolved[2]->name);
+ $this->assertEquals('button', $resolved[3]->name);
+ $this->assertEquals('icon', $resolved[4]->name);
+ }
+
+ public function testCircularDependency(): void
+ {
+ $registry = new Registry();
+
+ $registry->add(
+ new RegistryItem(
+ 'cell',
+ RegistryItemType::Component,
+ 'default',
+ 'row',
+ ''
+ )
+ );
+
+ $registry->add(
+ new RegistryItem(
+ 'table',
+ RegistryItemType::Component,
+ 'default',
+ 'cell',
+ ''
+ )
+ );
+
+ $registry->add(
+ new RegistryItem(
+ 'row',
+ RegistryItemType::Component,
+ 'default',
+ 'table',
+ ''
+ )
+ );
+
+ $resolver = new DependenciesResolver();
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Circular dependency detected: table -> cell');
+ $resolver->resolve($registry);
+ }
+}
diff --git a/src/Toolkit/tests/UxToolkitBundleTest.php b/src/Toolkit/tests/UxToolkitBundleTest.php
new file mode 100644
index 00000000000..95d769f46f4
--- /dev/null
+++ b/src/Toolkit/tests/UxToolkitBundleTest.php
@@ -0,0 +1,32 @@
+
+ *
+ * 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\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\UX\Toolkit\DependencyInjection\ToolkitExtension;
+use Symfony\UX\Toolkit\UxToolkitBundle;
+
+/**
+ * @author Jean-François Lépine
+ */
+class UxToolkitBundleTest extends KernelTestCase
+{
+ public function testBundleBuildsSuccessfully(): void
+ {
+ self::bootKernel();
+ $container = self::$kernel->getContainer();
+
+ $this->assertInstanceOf(UxToolkitBundle::class, $container->get('kernel')->getBundles()['UxToolkitBundle']);
+ }
+}
diff --git a/src/Toolkit/tests/bootstrap.php b/src/Toolkit/tests/bootstrap.php
new file mode 100644
index 00000000000..3a6f67b1c33
--- /dev/null
+++ b/src/Toolkit/tests/bootstrap.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+use Symfony\Component\Filesystem\Filesystem;
+
+require __DIR__.'/../vendor/autoload.php';
+
+(new Filesystem())->remove(__DIR__.'/../var');
diff --git a/src/Toolkit/tests/ui/components/badge-destructive.html b/src/Toolkit/tests/ui/components/badge-destructive.html
new file mode 100644
index 00000000000..ba7bb4edae8
--- /dev/null
+++ b/src/Toolkit/tests/ui/components/badge-destructive.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+my badge
+
+
\ No newline at end of file
diff --git a/src/Toolkit/tests/ui/generate-html b/src/Toolkit/tests/ui/generate-html
new file mode 100755
index 00000000000..3f3eedee178
--- /dev/null
+++ b/src/Toolkit/tests/ui/generate-html
@@ -0,0 +1,43 @@
+#!/usr/bin/env php
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/*
+ * @author Jean-François Lépine
+ */
+
+use Twig\Extra\Html\HtmlExtension;
+
+require __DIR__ . '/../../vendor/autoload.php';
+
+// mount he microkernel
+$kernel = new Symfony\UX\Toolkit\Tests\Fixtures\Kernel('test', true);
+$kernel->boot();
+$container = $kernel->getContainer()->get('test.service_container');
+$twig = $container->get('twig');
+$twig->addExtension(new HtmlExtension());
+
+// list files in the components dir
+$files = glob(__DIR__ . '/components/*.html');
+
+// for each, we generate a corresponding file into the output dir
+foreach ($files as $file) {
+ $name = pathinfo($file, PATHINFO_FILENAME);
+ echo " - " . $name . "\n";
+ $html = file_get_contents($file);
+
+ $template = $twig->createTemplate($html);
+ $output = $template->render([]);
+
+ file_put_contents(__DIR__ . '/output/' . $name . '.html', $output);
+}
+
+echo "Done\n";
\ No newline at end of file
diff --git a/src/Toolkit/tests/ui/reference/.keepme b/src/Toolkit/tests/ui/reference/.keepme
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/Toolkit/tests/ui/suite b/src/Toolkit/tests/ui/suite
new file mode 100755
index 00000000000..7c5f5165b76
--- /dev/null
+++ b/src/Toolkit/tests/ui/suite
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+set -e
+
+#
+# This file is part of the Symfony package.
+#
+# (c) Fabien Potencier
+#
+# For the full copyright and license information, please view the LICENSE
+# file that was distributed with this source code.
+#
+#
+# @author Jean-François Lépine
+#
+
+#
+# This script is used to compare screenshots of the components. It generates HTML from twig files, then generates screenshots
+# and compares them with the reference images.
+#
+# Usage:
+#
+# ./suite <--capture>
+#
+
+PERCENTAGE_THRESHOLD=10
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+# Ensure PHP is installed
+if ! [ -x "$(command -v php)" ]; then
+ echo 'Error: PHP is not installed.' >&2
+ exit 1
+fi
+
+# Ensure pageres is installed
+if ! [ -x "$(command -v pageres)" ]; then
+ echo 'Error: pageres is not installed. Please run `npm install -g pageres-cli`.' >&2
+ exit 1
+fi
+
+# check if we should generate reference images
+if [ "$1" == "--capture" ]; then
+ generateReference=true
+else
+ generateReference=false
+fi
+
+referenceDir=$DIR/reference
+if [ ! -d "$referenceDir" ]; then
+ mkdir -p $referenceDir
+fi
+
+# Ensure reference is not empty
+if [ ! "$(ls -A $referenceDir)" ]; then
+ if [ "$generateReference" = false ]; then
+ echo "Reference directory is empty. Please run again with --capture flag"
+ exit 1
+ fi
+fi
+
+if [ "$generateReference" = true ]; then
+ rm -Rf $DIR/reference/*
+fi
+
+rm -Rf $DIR/output/*
+rm -Rf $DIR/screens/*
+mkdir -p $DIR/output
+mkdir -p $DIR/screens
+
+# Generating HTML files from twig components
+echo "Generating HTML files..."
+./generate-html
+
+# Generating corresponding images
+echo "Generating screenshots..."
+files=( $DIR/output/*.html )
+for file in "${files[@]}"
+do
+ name=$(basename $file)
+ destination=screens/${name%.*}
+ pageres $file --filename=$destination
+done
+
+if [ "$generateReference" = true ]; then
+ echo "Reference images generated."
+ cp $DIR/screens/* $DIR/reference
+ exit 0
+fi
+
+# Comparing images
+echo "Comparing images..."
+for file in $DIR/screens/*.png
+do
+ name=$(basename $file)
+ reference=$referenceDir/$name
+
+ differenceInPercent=$(convert -metric AE $reference $file -trim -compare -format "%[distortion]" info:)
+ differenceInPercent=${differenceInPercent%.*}
+
+ echo "- $name: $differenceInPercent%"
+
+ if [ $differenceInPercent -gt $PERCENTAGE_THRESHOLD ]; then
+ echo "Difference between $reference and $file is $differenceInPercent%"
+ exit 1
+ fi
+done
+
+echo
+cnt=$(ls -1 $DIR/screens/*.png | wc -l)
+echo "All images ($cnt) are within the threshold of $PERCENTAGE_THRESHOLD% difference."
+echo
\ No newline at end of file