diff --git a/ux.symfony.com/assets/images/cookbook/component_architecture.png b/ux.symfony.com/assets/images/cookbook/component_architecture.png new file mode 100644 index 00000000000..e3e417da832 Binary files /dev/null and b/ux.symfony.com/assets/images/cookbook/component_architecture.png differ diff --git a/ux.symfony.com/assets/styles/app.scss b/ux.symfony.com/assets/styles/app.scss index d2b2e33c5b5..9c4172d4e4a 100644 --- a/ux.symfony.com/assets/styles/app.scss +++ b/ux.symfony.com/assets/styles/app.scss @@ -82,6 +82,7 @@ @import "components/ProductGrid"; @import "components/PackageHeader"; @import "components/PackageBox"; +@import "components/Cookbook"; @import "components/Tabs"; @import "components/Tag"; @import "components/Terminal"; diff --git a/ux.symfony.com/assets/styles/components/_Cookbook.scss b/ux.symfony.com/assets/styles/components/_Cookbook.scss new file mode 100644 index 00000000000..70059794d24 --- /dev/null +++ b/ux.symfony.com/assets/styles/components/_Cookbook.scss @@ -0,0 +1,79 @@ +.Cookbook { + h1 { + margin-top: 3rem; + margin-bottom: 1rem; + text-align: center; + font-size: 52px; + font-weight: 700; + line-height: 60px; + } + + .description { + text-align: center; + font-size: 24px; + font-weight: 600; + margin-top: 1.5rem; + } + + .tags { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + gap: 1rem; + text-decoration: none; + list-style: none; + margin-bottom: 3rem; + + li { + background-color: rgb(74 29 150); + color: rgb(202 191 253); + font-weight: 500; + font-size: 0.75rem; + line-height: 1rem; + padding: .125rem .625rem; + border-radius: 0.25rem; + } + } + + .image-title { + width: 100%; + max-height: 40vh; + overflow: hidden; + border-radius: 4px; + margin-bottom: 3rem; + + img { + display: block; + object-fit: contain; + width: 100%; + } + } + + .content { + h2 { + margin-top: 3rem; + margin-bottom: 1rem; + font-size: 32px; + font-weight: 700; + line-height: 40px; + } + + h3 { + margin-top: 3rem; + margin-bottom: 1rem; + font-size: 24px; + font-weight: 700; + line-height: 32px; + color: #FFFFFF; + } + } + + pre { + margin-top: 4rem; + margin-bottom: 2rem; + border-radius: 4px; + background-color: #0A0A0A; + padding: 2rem; + } +} \ No newline at end of file diff --git a/ux.symfony.com/composer.lock b/ux.symfony.com/composer.lock index 804cc6286e8..f5ea798539c 100644 --- a/ux.symfony.com/composer.lock +++ b/ux.symfony.com/composer.lock @@ -12418,5 +12418,5 @@ "ext-iconv": "*" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/ux.symfony.com/config/services.yaml b/ux.symfony.com/config/services.yaml index 95f38b16b4c..2407f3f014c 100644 --- a/ux.symfony.com/config/services.yaml +++ b/ux.symfony.com/config/services.yaml @@ -10,6 +10,8 @@ services: _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + string $cookbookPath: '%kernel.project_dir%/cookbook' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/ux.symfony.com/cookbook/architecture_component.md b/ux.symfony.com/cookbook/architecture_component.md new file mode 100644 index 00000000000..35148bc557e --- /dev/null +++ b/ux.symfony.com/cookbook/architecture_component.md @@ -0,0 +1,178 @@ +--- +title: Architecture component +description: Rules and pattern to work with components +image: images/cookbook/component_architecture.png +tags: + - javascript + - symfony +--- + +## Introduction + +In SymfonyUX exist two packages: [TwigComponents](https://symfony.com/bundles/ux-twig-component/current/index.html) and [LiveComponent](https://symfony.com/bundles/ux-live-component/current/index.html). +Those two packages allow you to create reusable components in your Symfony application. +But the component architecture is not exclusive to Symfony, it is a design pattern that can be applied to any programming language or framework. +And the js world already implement this architecture for long time, on many different frameworks like React, Vue, or Svelte. +So, a set of rules and pattern has already be defined to work with components. This is why SymfonyUX try to be as close as possible to those rules. +So let's see what are those rules! + +## 4 Rules + +### Composition + +A page is no longer just a page, but rather a collection of small, reusable components. +These components can be assembled to form a page. For example, there could be a component for the title and another for the training list. +The training list component could even be composed of smaller components, such as a training card component. +The goal is to create the most atomic, and reusable components possible. + +#### How does it work into Symfony? + +In Symfony you can have a component Alert for example with the following template: + +```twig +
+ + {{ message }} +
+``` + +So here you can see we have an alert component that his himself use an Icon component. +Or you can make composition with the following syntax: + +```twig + + +

My Card

+
+ +

This is the content of my card.

+
+
+``` + +So here we Card component, and we give to the content of this component mutliple other components. + +### Independence + +This is a really important rule, and not obvious. But your component should leave on his own context, +he should not be aware of the rest of the page. You should to talk one component into a page, to another and it should work exactly the same. +This rule make your component trully reusable. + +***How does it work into Symfony?*** + +Symfony keep the context of the page into the context of your component. So this your own responsability to follow this rules. +But notice that if there are conflic between a variable from the context page and your component, your component context override the page context. + +### Props + +Our component must remain independent, but we can customize it props. +Let's take the example of a button component. You have your component that look on every page the same, +the only change is the label. What you can do is to declare a prop `label` into your button component. +And so now when you want to use your button component, you can pass the label you want as props. The component gonna take +this props at his initialization and keep it all his life long. + +***How does it work into Symfony?*** + +Let's take the example of the Alert component an [anonymous component](https://symfony.com/bundles/ux-twig-component/current/index.html#anonymous-components). +We have the following template: + +```twig +{% props type, icon, message %} + +
+ + {{ message }} +
+``` + +Just like that we define three props for our Alert component. And know we can use like this: + +```twig + +``` + +If your component anonymous but a class component, you can simply define props +by adding property to your class. + +```php +class Alert +{ + public string $type; + public string $icon; + public string $message; +} +``` + +There are something really important to notice with props. It's your props +should only go into one direction from the parent to child. But your props should never +go up. **If your child need to change something in the parent, you should use events**. + +### State + +A state is pretty much like a prop but the main difference is a state can +change during the life of the component. Let's take the example of a button component. +You can have a state `loading` that can be `true` or `false`. When the button is clicked +the state `loading` can be set to `true` and the button can display a loader instead of the label. +And when the loading is done, the state `loading` can be set to `false` and the button can display the label again. + +***How does it work into Symfony?*** + +In symfony you 2 different approach to handle state. The first one is to use stimulus directly +in to your component. What we recommend to do is to set a controller stimulus at the root of your component. + +```twig +{% props label %} + + +``` + +And then you can define your controller like this: + +```js +import { Controller } from 'stimulus'; + +export default class extends Controller { + static values = { label: String }; + + connect() { + this.element.textContent = this.labelValue; + } + + loading() { + this.element.textContent = 'Loading...'; + } +} +``` + +The second approach is to use the [LiveComponent](https://symfony.com/bundles/ux-live-component/current/index.html) package. +How to choose between the two? If your component don't need any backend logic +for his state keep it simple and use stimulus approach. But if you need to handle +backend logic for your state, use LiveComponent. +With live component a live prop is a state. So if you want store the number of click on a button you can do +the following component: + +```php +clicks++; + + $this->save(); + } +} +``` + +## Conclusion + +Even in Symfony, you can use the component architecture. +Follow those rules help your front developpers working on codebase +their are familiar with since those rules are already used in the js world. diff --git a/ux.symfony.com/src/Controller/CookbookController.php b/ux.symfony.com/src/Controller/CookbookController.php new file mode 100644 index 00000000000..b5ea2b94a17 --- /dev/null +++ b/ux.symfony.com/src/Controller/CookbookController.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 App\Controller; + +use App\Service\CookbookFactory; +use App\Service\CookbookRepository; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +class CookbookController extends AbstractController +{ + public function __construct( + private CookbookRepository $cookbookRepository, + private CookbookFactory $cookbookFactory, + ) { + } + + #[Route('/cookbook', name: 'app_cookbook_index')] + public function index(): Response + { + $cookbooks = $this->cookbookRepository->findAll(); + + return $this->render('cookbook/index.html.twig', [ + 'cookbooks' => $cookbooks, + ]); + } + + #[Route('/cookbook/{slug}', name: 'app_cookbook_show')] + public function show(string $slug): Response + { + $cookbook = $this->cookbookRepository->findOneByName($slug); + + return $this->render('cookbook/show.html.twig', [ + 'slug' => $slug, + 'cookbook' => $cookbook, + ]); + } +} diff --git a/ux.symfony.com/src/Model/Cookbook.php b/ux.symfony.com/src/Model/Cookbook.php new file mode 100644 index 00000000000..b1ba5cf7518 --- /dev/null +++ b/ux.symfony.com/src/Model/Cookbook.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Model; + +class Cookbook +{ + public function __construct( + public string $title, + public string $description, + public string $route, + public string $image, + public string $content, + /** + * @var string[] + */ + public array $tags = [], + ) { + } +} diff --git a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php index 452586ef60c..4313b26263d 100644 --- a/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php +++ b/ux.symfony.com/src/Service/CommonMark/ConverterFactory.php @@ -13,6 +13,7 @@ use League\CommonMark\CommonMarkConverter; use League\CommonMark\Extension\ExternalLink\ExternalLinkExtension; +use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\Mention\MentionExtension; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Tempest\Highlight\CommonMark\HighlightExtension; @@ -47,6 +48,7 @@ public function __invoke(): CommonMarkConverter ->addExtension(new ExternalLinkExtension()) ->addExtension(new MentionExtension()) ->addExtension(new HighlightExtension()) + ->addExtension(new FrontMatterExtension()) ; return $converter; diff --git a/ux.symfony.com/src/Service/CookbookFactory.php b/ux.symfony.com/src/Service/CookbookFactory.php new file mode 100644 index 00000000000..254f80fb685 --- /dev/null +++ b/ux.symfony.com/src/Service/CookbookFactory.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 App\Service; + +use App\Model\Cookbook; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +class CookbookFactory +{ + public function __construct( + private readonly CookbookParser $cookbookParser, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function buildFromFile(\SplFileInfo $file): Cookbook + { + $content = $file->getContents(); + + return new Cookbook( + title: $this->cookbookParser->getTitle($content), + description: $this->cookbookParser->getDescriptions($content), + route: $this->urlGenerator->generate('app_cookbook_show', ['slug' => $file->getBasename('.md')]), + image: $this->cookbookParser->getImage($content), + content: $content, + tags: $this->cookbookParser->getTags($content), + ); + } +} diff --git a/ux.symfony.com/src/Service/CookbookParser.php b/ux.symfony.com/src/Service/CookbookParser.php new file mode 100644 index 00000000000..1fec0e9985a --- /dev/null +++ b/ux.symfony.com/src/Service/CookbookParser.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service; + +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; +use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; +use League\CommonMark\MarkdownConverter; +use League\CommonMark\Output\RenderedContentInterface; + +class CookbookParser +{ + public function getTitle(string $content): string + { + return $this->getFrontMatterProperty('title', $content) ?? + throw new \RuntimeException('Title is required in a cookbook'); + } + + public function getDescriptions(string $content): string + { + return $this->getFrontMatterProperty('description', $content) ?? + throw new \RuntimeException('Description is required in a cookbook'); + } + + public function getImage(string $content): string + { + return $this->getFrontMatterProperty('image', $content) ?? + throw new \RuntimeException('Image is required in a cookbook'); + } + + public function getTags(string $content): array + { + return $this->getFrontMatterProperty('tags', $content) ?? + throw new \RuntimeException('Tags are required in a cookbook'); + } + + public function getContent(string $content): string + { + $result = $this->convert($content); + + return $result->getContent(); + } + + private function convert(string $content): RenderedContentInterface + { + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new FrontMatterExtension()); + + $converter = new MarkdownConverter($environment); + + return $converter->convert($content); + } + + private function getFrontMatterProperty(string $property, string $content) + { + $result = $this->convert($content); + + if (!$result instanceof RenderedContentWithFrontMatter) { + throw new \RuntimeException('FrontMatter can\'t parse the cookbook'); + } + + return $result->getFrontMatter()[$property]; + } +} diff --git a/ux.symfony.com/src/Service/CookbookRepository.php b/ux.symfony.com/src/Service/CookbookRepository.php new file mode 100644 index 00000000000..ebadaed29f6 --- /dev/null +++ b/ux.symfony.com/src/Service/CookbookRepository.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service; + +use App\Model\Cookbook; +use Symfony\Component\Finder\Finder; + +readonly class CookbookRepository +{ + public function __construct( + private CookbookFactory $cookbookFactory, + private string $cookbookPath, + ) { + } + + public function findOneByName(string $name): Cookbook + { + if (!file_exists($this->cookbookPath.'/'.$name.'.md')) { + throw new \RuntimeException('No cookbook found'); + } + + $finder = new Finder(); + $finder->files()->in($this->cookbookPath)->name($name.'.md'); + + $file = null; + foreach ($finder as $fileL) { + $file = $fileL; + } + + return $this->cookbookFactory->buildFromFile($file); + } + + /** + * @return Cookbook[] + */ + public function findAll(): array + { + $finder = new Finder(); + $finder->files()->in($this->cookbookPath)->name('*.md'); + + if (!$finder->hasResults()) { + throw new \RuntimeException('No cookbook found'); + } + + $cookbooks = []; + foreach ($finder as $file) { + $cookbooks[] = $this->cookbookFactory->buildFromFile($file); + } + + return $cookbooks; + } +} diff --git a/ux.symfony.com/templates/_header.html.twig b/ux.symfony.com/templates/_header.html.twig index 0ebd5d577bd..40c40dc96bc 100644 --- a/ux.symfony.com/templates/_header.html.twig +++ b/ux.symfony.com/templates/_header.html.twig @@ -35,12 +35,13 @@
- Turbo Live Components Icons + Turbo + Cookbook Packages Demos
diff --git a/ux.symfony.com/templates/components/Card.html.twig b/ux.symfony.com/templates/components/Card.html.twig new file mode 100644 index 00000000000..e537fd5bc0a --- /dev/null +++ b/ux.symfony.com/templates/components/Card.html.twig @@ -0,0 +1,30 @@ +{% props name, image, url, description, tags %} + +
+ +
+ {{ name }} demo preview +
+ +
+

+ + {{ name }} + +

+

+ {{ description }} +

+

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

+
+ +
diff --git a/ux.symfony.com/templates/components/Cookbook.html.twig b/ux.symfony.com/templates/components/Cookbook.html.twig new file mode 100644 index 00000000000..766a431940b --- /dev/null +++ b/ux.symfony.com/templates/components/Cookbook.html.twig @@ -0,0 +1,17 @@ +{% props cookbook %} + +
+

{{ cookbook.title }}

+ +

{{ cookbook.description }}

+
+ Stimulus and Symfony +
+
+ {{ cookbook.content|markdown_to_html }} +
+
diff --git a/ux.symfony.com/templates/cookbook/index.html.twig b/ux.symfony.com/templates/cookbook/index.html.twig new file mode 100644 index 00000000000..2d38c01939d --- /dev/null +++ b/ux.symfony.com/templates/cookbook/index.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% set meta = { + title: 'Cookbook', + title_suffix: ' - Symfony UX', + description: 'Symfony UX cookbook - Concrete exeample to understantd all the concepts and components of Symfony UX', + canonical: url('app_cookbook_index'), +} %} + +{% block content %} +
+
+

Cookbook

+

some recipes to show how to use the component and concept of SymfonyUx

+
+
+ +
+
+ {% for cookbook in cookbooks %} + + {% endfor %} +
+
+{% endblock %} diff --git a/ux.symfony.com/templates/cookbook/show.html.twig b/ux.symfony.com/templates/cookbook/show.html.twig new file mode 100644 index 00000000000..4314bf9a948 --- /dev/null +++ b/ux.symfony.com/templates/cookbook/show.html.twig @@ -0,0 +1,14 @@ +{% extends 'base.html.twig' %} + +{% set meta = { + title: cookbook.title, + title_suffix: ' - Symfony UX', + description: cookbook.description, + canonical: cookbook.route, +} %} + +{% block content %} +
+ +
+{% endblock %}