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 284f5a33188..f34f4da8085 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 @@