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 {% block content %}Button{% endblock %}\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, + 'foo
' + ) + ); + $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