diff --git a/ux.symfony.com/assets/images/cookbook/component-architecture-1280x720.png b/ux.symfony.com/assets/images/cookbook/component-architecture-1280x720.png new file mode 100644 index 00000000000..536b33e2cff Binary files /dev/null and b/ux.symfony.com/assets/images/cookbook/component-architecture-1280x720.png differ diff --git a/ux.symfony.com/assets/images/cookbook/component-architecture-640x360.png b/ux.symfony.com/assets/images/cookbook/component-architecture-640x360.png new file mode 100644 index 00000000000..202e943665d Binary files /dev/null and b/ux.symfony.com/assets/images/cookbook/component-architecture-640x360.png differ diff --git a/ux.symfony.com/assets/images/cookbook/component_architecture.png b/ux.symfony.com/assets/images/cookbook/component_architecture.png deleted file mode 100644 index e3e417da832..00000000000 Binary files a/ux.symfony.com/assets/images/cookbook/component_architecture.png and /dev/null differ diff --git a/ux.symfony.com/assets/styles/components/_Cookbook.scss b/ux.symfony.com/assets/styles/components/_Cookbook.scss index 70059794d24..489a2b8f564 100644 --- a/ux.symfony.com/assets/styles/components/_Cookbook.scss +++ b/ux.symfony.com/assets/styles/components/_Cookbook.scss @@ -1,51 +1,15 @@ .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; + width: auto; + border-radius: .75rem; + aspect-ratio: 16 / 9; + max-width: 1280px; img { display: block; - object-fit: contain; + object-fit: cover; width: 100%; } } @@ -65,15 +29,22 @@ font-size: 24px; font-weight: 700; line-height: 32px; - color: #FFFFFF; + color: var(--color-text); + } + + h4 { + margin-top: 3rem; + margin-bottom: 1rem; } } pre { - margin-top: 4rem; - margin-bottom: 2rem; - border-radius: 4px; + margin-block: 2rem; + border-radius: .75rem; background-color: #0A0A0A; - padding: 2rem; + padding: 1rem 1.5rem; + color: #d7e2e3; + font-size: 1rem; + line-height: 1.5; } -} \ No newline at end of file +} diff --git a/ux.symfony.com/assets/styles/components/_DemoCard.scss b/ux.symfony.com/assets/styles/components/_DemoCard.scss index 8335748e84e..cbcc965c8f2 100644 --- a/ux.symfony.com/assets/styles/components/_DemoCard.scss +++ b/ux.symfony.com/assets/styles/components/_DemoCard.scss @@ -17,6 +17,7 @@ box-shadow: 0 0 0 transparent; background: var(--bs-body-bg); } + .DemoCard:hover { transition: color 250ms ease-in-out, transform 250ms ease-in-out, box-shadow 250ms ease-in-out; transform: translateY(-.25rem); @@ -36,11 +37,12 @@ } .DemoCard__image { - max-height: 100%; - width: 100%; + max-width: 100%; display: block; + object-fit: cover; opacity: .90; } + .DemoCard:hover .DemoCard__image { opacity: .93; } @@ -71,6 +73,7 @@ line-height: 1.4; margin: 0; } + .DemoCard:hover .DemoCard__description { opacity: .85; } @@ -80,5 +83,5 @@ margin-bottom: 0; display: flex; flex-wrap: wrap; - gap: .5rem; + gap: .5rem; } diff --git a/ux.symfony.com/config/packages/twig.yaml b/ux.symfony.com/config/packages/twig.yaml index 97dcdf90a17..0f3efd37c31 100644 --- a/ux.symfony.com/config/packages/twig.yaml +++ b/ux.symfony.com/config/packages/twig.yaml @@ -4,6 +4,7 @@ twig: paths: '%kernel.project_dir%/templates/demos/live_memory': ~ + '%kernel.project_dir%/cookbook/': 'Cookbook' when@test: twig: diff --git a/ux.symfony.com/config/services.yaml b/ux.symfony.com/config/services.yaml index 2407f3f014c..95f38b16b4c 100644 --- a/ux.symfony.com/config/services.yaml +++ b/ux.symfony.com/config/services.yaml @@ -10,8 +10,6 @@ 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/component_architecture.md similarity index 96% rename from ux.symfony.com/cookbook/architecture_component.md rename to ux.symfony.com/cookbook/component_architecture.md index 35148bc557e..ce36127d72c 100644 --- a/ux.symfony.com/cookbook/architecture_component.md +++ b/ux.symfony.com/cookbook/component_architecture.md @@ -1,7 +1,7 @@ --- -title: Architecture component +title: Component architecture description: Rules and pattern to work with components -image: images/cookbook/component_architecture.png +image: images/cookbook/component-architecture.png tags: - javascript - symfony @@ -174,5 +174,5 @@ class Button ## 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. +Follow those rules help your front developers working on codebase +they 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 index b5ea2b94a17..2aa2d1f7a9a 100644 --- a/ux.symfony.com/src/Controller/CookbookController.php +++ b/ux.symfony.com/src/Controller/CookbookController.php @@ -11,21 +11,20 @@ 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; +use Symfony\Component\Routing\Requirement\Requirement; class CookbookController extends AbstractController { public function __construct( - private CookbookRepository $cookbookRepository, - private CookbookFactory $cookbookFactory, + private readonly CookbookRepository $cookbookRepository, ) { } - #[Route('/cookbook', name: 'app_cookbook_index')] + #[Route('/cookbook', name: 'app_cookbook')] public function index(): Response { $cookbooks = $this->cookbookRepository->findAll(); @@ -35,13 +34,15 @@ public function index(): Response ]); } - #[Route('/cookbook/{slug}', name: 'app_cookbook_show')] + #[Route('/cookbook/{slug}', name: 'app_cookbook_show', requirements: ['slug' => Requirement::ASCII_SLUG])] public function show(string $slug): Response { - $cookbook = $this->cookbookRepository->findOneByName($slug); + $cookbook = $this->cookbookRepository->findOneBySlug($slug); + if (!$cookbook) { + throw $this->createNotFoundException(\sprintf('Cookbook "%s" not found', $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 index b1ba5cf7518..f5aa4a00c30 100644 --- a/ux.symfony.com/src/Model/Cookbook.php +++ b/ux.symfony.com/src/Model/Cookbook.php @@ -11,18 +11,18 @@ namespace App\Model; -class Cookbook +final readonly class Cookbook { + /** + * @param list $tags + */ public function __construct( public string $title, - public string $description, - public string $route, + public string $slug, public string $image, + public string $description, public string $content, - /** - * @var string[] - */ - public array $tags = [], + public array $tags, ) { } } diff --git a/ux.symfony.com/src/Service/CookbookFactory.php b/ux.symfony.com/src/Service/CookbookFactory.php index 254f80fb685..d9edd2e6515 100644 --- a/ux.symfony.com/src/Service/CookbookFactory.php +++ b/ux.symfony.com/src/Service/CookbookFactory.php @@ -12,27 +12,51 @@ namespace App\Service; use App\Model\Cookbook; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use League\CommonMark\Extension\FrontMatter\Data\SymfonyYamlFrontMatterParser; +use League\CommonMark\Extension\FrontMatter\FrontMatterParser; +use League\CommonMark\Extension\FrontMatter\FrontMatterParserInterface; -class CookbookFactory +final class CookbookFactory { - public function __construct( - private readonly CookbookParser $cookbookParser, - private readonly UrlGeneratorInterface $urlGenerator, - ) { + private FrontMatterParserInterface $frontMatterParser; + + public function __construct() + { + $this->frontMatterParser = new FrontMatterParser(new SymfonyYamlFrontMatterParser()); } - public function buildFromFile(\SplFileInfo $file): Cookbook + public function createFromFile(string $file): Cookbook { - $content = $file->getContents(); + if (!file_exists($file)) { + throw new \InvalidArgumentException(\sprintf('File "%s" not found.', $file)); + } + + $content = file_get_contents($file); + + if (!\is_array($frontMatter = $this->frontMatterParser->parse($content)->getFrontMatter())) { + throw new \RuntimeException(\sprintf('Cookbook file "%s" does not contains Front Matter data.', $file)); + } + + if (!isset($frontMatter['title']) || !\is_string($frontMatter['title'])) { + throw new \RuntimeException('Missing title in Front Matter.'); + } + if (!isset($frontMatter['description']) || !\is_string($frontMatter['description'])) { + throw new \RuntimeException('Missing description in Front Matter.'); + } + if (!isset($frontMatter['image']) || !\is_string($frontMatter['image'])) { + throw new \RuntimeException('Missing image in Front Matter.'); + } + if (!isset($frontMatter['tags']) || !\is_array($frontMatter['tags'])) { + throw new \RuntimeException('Missing tags in Front Matter.'); + } 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), + title: $frontMatter['title'], + slug: str_replace('_', '-', basename($file, '.md')), + image: $frontMatter['image'], + description: $frontMatter['description'], content: $content, - tags: $this->cookbookParser->getTags($content), + tags: $frontMatter['tags'], ); } } diff --git a/ux.symfony.com/src/Service/CookbookParser.php b/ux.symfony.com/src/Service/CookbookParser.php deleted file mode 100644 index 1fec0e9985a..00000000000 --- a/ux.symfony.com/src/Service/CookbookParser.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * 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 index ebadaed29f6..f5450db0262 100644 --- a/ux.symfony.com/src/Service/CookbookRepository.php +++ b/ux.symfony.com/src/Service/CookbookRepository.php @@ -12,50 +12,50 @@ namespace App\Service; use App\Model\Cookbook; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Finder\Finder; readonly class CookbookRepository { public function __construct( private CookbookFactory $cookbookFactory, - private string $cookbookPath, + #[Autowire('%kernel.project_dir%/cookbook')] private string $cookbookDirectory, ) { } - public function findOneByName(string $name): Cookbook + public function findOneBySlug(string $slug): ?Cookbook { - if (!file_exists($this->cookbookPath.'/'.$name.'.md')) { - throw new \RuntimeException('No cookbook found'); + if (!preg_match('/^[a-z]+(-[a-z0-9]+)*$/', $slug)) { + throw new \InvalidArgumentException(\sprintf('Invalid slug provided: "%s".', $slug)); } - $finder = new Finder(); - $finder->files()->in($this->cookbookPath)->name($name.'.md'); - - $file = null; - foreach ($finder as $fileL) { - $file = $fileL; + if (!file_exists($filename = $this->getCookbookFilename($slug))) { + return null; } - return $this->cookbookFactory->buildFromFile($file); + return $this->cookbookFactory->createFromFile($filename); } /** - * @return Cookbook[] + * @return list */ public function findAll(): array { - $finder = new Finder(); - $finder->files()->in($this->cookbookPath)->name('*.md'); - - if (!$finder->hasResults()) { - throw new \RuntimeException('No cookbook found'); - } + $files = (new Finder()) + ->files() + ->in($this->cookbookDirectory) + ->name('*.md'); $cookbooks = []; - foreach ($finder as $file) { - $cookbooks[] = $this->cookbookFactory->buildFromFile($file); + foreach ($files as $file) { + $cookbooks[] = $this->cookbookFactory->createFromFile($file); } return $cookbooks; } + + private function getCookbookFilename(string $slug): string + { + return $this->cookbookDirectory.'/'.str_replace('-', '_', $slug).'.md'; + } } diff --git a/ux.symfony.com/templates/_header.html.twig b/ux.symfony.com/templates/_header.html.twig index 40c40dc96bc..057245624d7 100644 --- a/ux.symfony.com/templates/_header.html.twig +++ b/ux.symfony.com/templates/_header.html.twig @@ -35,14 +35,14 @@ diff --git a/ux.symfony.com/templates/components/Card.html.twig b/ux.symfony.com/templates/components/Card.html.twig index e537fd5bc0a..72474c38bdb 100644 --- a/ux.symfony.com/templates/components/Card.html.twig +++ b/ux.symfony.com/templates/components/Card.html.twig @@ -1,24 +1,27 @@ -{% props name, image, url, description, tags %} +{% props name, image, url, description, tags, lazyload = true %}
{{ name }} demo preview

- {{ name }} + {{- name -}}

- {{ description }} + {{- description -}}

{% for tag in tags %} diff --git a/ux.symfony.com/templates/cookbook/index.html.twig b/ux.symfony.com/templates/cookbook/index.html.twig index 2d38c01939d..98b3b13fee8 100644 --- a/ux.symfony.com/templates/cookbook/index.html.twig +++ b/ux.symfony.com/templates/cookbook/index.html.twig @@ -4,28 +4,38 @@ 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'), + canonical: url('app_cookbook'), } %} {% block content %} +

-
-

Cookbook

-

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

+
+

Cookbook

+
+

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

+
-
+
{% for cookbook in cookbooks %} {% endfor %}
{% endblock %} + +{% block aside %} + {{ include('_aside.html.twig') }} +{% endblock %} diff --git a/ux.symfony.com/templates/cookbook/show.html.twig b/ux.symfony.com/templates/cookbook/show.html.twig index 4314bf9a948..6b6370ad2c8 100644 --- a/ux.symfony.com/templates/cookbook/show.html.twig +++ b/ux.symfony.com/templates/cookbook/show.html.twig @@ -2,13 +2,63 @@ {% set meta = { title: cookbook.title, - title_suffix: ' - Symfony UX', + title_suffix: ' - Cookbook - Symfony UX', description: cookbook.description, - canonical: cookbook.route, + canonical: url('app_cookbook_show', {slug: cookbook.slug}), + social: { + title: cookbook.title ~ ' - Symfony UX', + description: cookbook.description, + image: { + url: absolute_url(asset('images/cookbook/%s-1280x720.png'|format(cookbook.slug))), + type: 'image/png', + width: 1280, + height: 720, + alt: cookbook.title ~ ' - Cookbook Illustration', + }, + }, } %} {% block content %} -
- -
+ +
+
+
+
+

+ Cookbook +

+

{{ cookbook.title }}

+

+ {{- cookbook.description -}} +

+ {% if false and cookbook.tags %} +
    + {% for tag in cookbook.tags %} +
  • {{ tag }}
  • + {% endfor %} +
+ {% endif %} +
+
+
+ {{ cookbook.title }} Illustration +
+
+
+
+ +
+
+ {{ cookbook.content|raw|markdown_to_html }} +
+
+ +
+{% endblock %} + +{% block aside %} + {{ include('_aside.html.twig') }} {% endblock %}