diff --git a/config/services.php b/config/services.php index 9533f90..cf58801 100644 --- a/config/services.php +++ b/config/services.php @@ -123,8 +123,8 @@ ->tag( 'kernel.event_listener', [ - 'event' => 'kernel.controller', - 'method' => 'onKernelController', + 'event' => 'kernel.controller_arguments', + 'method' => 'onKernelControllerArguments', 'priority' => -1 ] ) @@ -138,8 +138,8 @@ ->tag( 'kernel.event_listener', [ - 'event' => 'kernel.controller', - 'method' => 'onKernelController', + 'event' => 'kernel.controller_arguments', + 'method' => 'onKernelControllerArguments', 'priority' => -1 ] ) diff --git a/docs/development/breadcrumb.md b/docs/development/breadcrumb.md index 350c03a..a7c778e 100644 --- a/docs/development/breadcrumb.md +++ b/docs/development/breadcrumb.md @@ -1,193 +1,171 @@ # Using the breadcrumb -The breadcrumb is a nice way to indicate where a user is in the application. +## Basics -## Manual breadcrumbs -Adding a breadcrumb is as simple as adding a `#[Breadcrumb()]` attribute. +Add a `#[Breadcrumb]` attribute to a controller method to register a crumb. The attribute is repeatable — each one +appends a crumb to the trail in declaration order. -Example: single ```php -#[Route('single', name:'single')] -#[Breadcrumb('single')] -public function __invoke(): Response -{ -} +use SumoCoders\FrameworkCoreBundle\Attribute\Breadcrumb; ``` -Example: attributes on the class +Single crumb: + ```php -#[Route('single', name:'single')] -#[Breadcrumb('single')] -class Controller +#[Route('/books', name: 'books_overview')] +#[Breadcrumb('books')] +public function __invoke(): Response { - public function __invoke(): Response - { - } } ``` -Adding multiple attributes will chain the breadcrumb. +Chained crumbs on one controller: -Example: single > multiple ```php -#[Route('/single/multiple', name:'multiple')] -#[Breadcrumb('single')] -#[Breadcrumb('multiple')] +#[Route('/books/genres', name: 'genres_overview')] +#[Breadcrumb('books')] +#[Breadcrumb('genres')] public function __invoke(): Response { } ``` -When needed a route can be added to the breadcrumb. +## Class-level attributes + +`#[Breadcrumb]` can be placed on the class instead of the method. Class attributes are only picked up for `__invoke` +controllers, or for named methods that also have at least one `#[Breadcrumb]` attribute of their own. -Example: [single](#) > multiple ```php -#[Route('/single/multiple', name:'multiple')] -#[Breadcrumb('single', route:['name' => 'single'])] -#[Breadcrumb('multiple')] -public function __invoke(): Response +#[Breadcrumb('books')] +class BooksController { + public function __invoke(): Response + { + } } ``` -Route parameters are also supported, but can only accept fixed values so are less useful to build dynamic breadcrumbs. + +## Linked breadcrumbs (`route:`) + +Pass `route:` to make the crumb a link. Required route parameters are automatically resolved from the current +controller's named arguments and request attributes — you do not need to specify them manually. + ```php -#[Route('/single/{foo}', name:'multiple')] -#[Breadcrumb('single', route:['name' => 'single', 'parameters' => ['foo' => 'bar']])] -public function __invoke(string $foo): Response +#[Route('/books/genres', name: 'genres_overview')] +#[Breadcrumb('books', route: ['name' => 'books_overview'])] +#[Breadcrumb('genres')] +public function __invoke(): Response { } ``` -## Automated breadcrumbs -A breadcrumb trail can be generated by passing the name of another route to the attribute. +When a parameter cannot be resolved automatically, you can supply a fixed value. This is rarely needed and ties the +breadcrumb to a hardcoded value: + +```php +#[Breadcrumb('section', route: ['name' => 'section_detail', 'parameters' => ['id' => 42]])] +``` -The parent route **must have a breadcrumb attribute as well**, as the chain is built by parsing and resolving the attribute in the given parent route. +## Parent route chaining (`parent:`) -Parents are always rendered as a link. Parameters of the parent route are copied from the base route and the current request. +Pass `parent:` to automatically prepend the full breadcrumb trail of another route. The parent route must have its own +`#[Breadcrumb]` attribute. Parameters are resolved from the current request. -Example: [books](#) > genres ```php -#[Route('/books', name:'books_overview')] +#[Route('/books', name: 'books_overview')] #[Breadcrumb('books')] public function __invoke(): Response { } -#[Route('/books/genres', name:'genres_overview')] -#[Breadcrumb('genres', parent:['name' => 'books_overview'])] +#[Route('/books/genres', name: 'genres_overview')] +#[Breadcrumb('genres', parent: ['name' => 'books_overview'])] public function __invoke(): Response { } ``` -## Rendering parameters in the breadcrumb -You can reference the passed parameter in your breadcrumb by putting it between curly brackets in the breadcrumb title. +The chain is resolved recursively, so parents of parents work as long as each route in the chain has `#[Breadcrumb]`. -Example: Authors > whatever-you-pass-as-$name -```php -#[Route('/author/{name}')] -#[Breadcrumb('authors')] -#[Breadcrumb('{name}')] -public function __invoke(string $name): Response -{ -} -``` -When ParamConverters are used it's possible to use a property of the object as breadcrumb value. +## Dynamic titles from object properties + +Use `{object.property}` to read a value from a controller argument at request time: -Example: Books > Harry Potter ```php -#[Route('/book/{book}')] -#[Breadcrumb('books')] +#[Route('/books/{book}', name: 'book_detail')] +#[Breadcrumb('books', route: ['name' => 'books_overview'])] #[Breadcrumb('{book.title}')] public function __invoke(Book $book): Response { } ``` -This also works with chained parent attributes, as long as the parameter names are still present in the route. - -Example: J.K. Rowling > Harry Potter -```php -#[Route('/{author}', name:'author_detail')] -#[Breadcrumb('{author.name}')] -public function __invoke(Author $author): Response -{ -} - -// ! If we'd omit the /{author} from this route, this example would no longer work ! -#[Route('/{author}/{book}', name:'book_detail')] -#[Breadcrumb('{book.title}', parent:['name' => 'author_detail'])] -public function __invoke(Author $author, Book $book): Response -{ -} -``` -If you reference a parent route with required parameters, and the required parameter cannot be resolved from the current request, an error will be thrown. +This also works when combined with `parent:`, as long as the required route parameters are present in the URL: -You can also supply parameters yourself, just like you would with a route, but this has the same drawbacks. You can only pass fixed values, which kind of defeats the purpose of building dynamic breadcrumbs. It is strongly recommended to **match your breadcrumbs to your URL structure**. ```php -#[Route('/{book}', name:'book_detail')] -#[Breadcrumb('{book.title}', parent:['name' => 'author_detail', 'parameters' => ['author' => 4]])] +// ! /{author} must be in the route for parameter resolution to work +#[Route('/{author}/{book}', name: 'book_detail')] +#[Breadcrumb('{book.title}', parent: ['name' => 'author_detail'])] public function __invoke(Author $author, Book $book): Response { } ``` -Custom ParamConverters are **not** supported. Only basic Doctrine Entity -> ID will work out of the box. +> Scalar parameters (e.g. `string $name`) cannot be used with the `{name}` syntax — only objects with a property path +> are supported. Using a scalar silently omits the breadcrumb. ## Translations -It's possible to use translations as these get translated in the template. -Example: Authors +All breadcrumb titles pass through the `|trans` Twig filter when rendered. Translation keys work out of the box: + ```yaml -breadcrumb.authors: 'Authors' +# translations/messages.en.yaml +breadcrumb.books: 'Books' ``` ```php -#[Route('/authors/{author}', name:'authors')] -#[Breadcrumb('breadcrumb.authors')] -public function book(Author $author): Response -{ -} +#[Breadcrumb('breadcrumb.books')] ``` - -It's also possible to add paramters to the translation. +For parameterized translations, pass `parameters:` as an array where keys are the translation placeholders and values +are `object.property` paths resolved from the current named arguments: ```yaml -breadcrumb.authors: 'Author: %name%' +breadcrumb.author_detail: 'Author: %name%' ``` ```php -#[Route('/authors/{author}', name:'authors')] -#[Breadcrumb('breadcrumb.authors', parameters: ['%name%' => 'author.name -public function book(Author $author): Response +#[Route('/author/{author}', name: 'author_detail')] +#[Breadcrumb('breadcrumb.author_detail', parameters: ['%name%' => 'author.name'])] +public function __invoke(Author $author): Response { } ``` ## Full example -Auteurs > J.K. Rowling > Harry Potter + +Trail: Authors > J.K. Rowling > Harry Potter ```php -#[Route('/author', name:'author_overview')] -#[Breadcrumb('authors')] +#[Route('/authors', name: 'author_overview')] +#[Breadcrumb('breadcrumb.authors')] public function __invoke(): Response { } -#[Route('/author/{author}', name:'author_detail')] -#[Breadcrumb('{author.name}', parent:['name' => 'author_overview']))] +#[Route('/authors/{author}', name: 'author_detail')] +#[Breadcrumb('{author.name}', parent: ['name' => 'author_overview'])] public function __invoke(Author $author): Response { } -#[Route('/{author}/{book}', name:'book_detail')] -#[Breadcrumb('{book.title}', parent:['name' => 'author_detail'])] +#[Route('/authors/{author}/{book}', name: 'book_detail')] +#[Breadcrumb('{book.title}', parent: ['name' => 'author_detail'])] public function __invoke(Author $author, Book $book): Response { } ``` ```yaml -authors: 'Auteurs' +breadcrumb.authors: 'Authors' ``` diff --git a/docs/development/title.md b/docs/development/title.md index cfe71d3..786a55b 100644 --- a/docs/development/title.md +++ b/docs/development/title.md @@ -1,57 +1,132 @@ -# The page title -By default no page title should be set. This is because by default the `fallback.site_title` is used. This is a configuration value that can be set in the `services.yaml` file. -In case there are breadcrumbs configured, the reverse order of the breadcrumbs will be used as the page title. +# Page title -## The `Title` attribute +## Resolution order -The `Title` attribute is a custom attribute used in the framework. It is used to set the title of a page dynamically based on the controller method that is being executed. Here's a step-by-step guide on how to use it: +The `PageTitle` service resolves the title in this order: -1. Import the `Title` attribute at the top of your controller file: +1. A title set explicitly via `#[Title]` on the controller method. +2. The breadcrumb trail in reverse order, joined with ` - `, appended with the site title. +3. The `fallback.site_title` value alone if no breadcrumbs are present. + +## Configuring the site title + +Set `fallback.site_title` in your `services.yaml`: + +```yaml +parameters: + fallbacks: + site_title: 'My Application' +``` + +## The `#[Title]` attribute ```php use SumoCoders\FrameworkCoreBundle\Attribute\Title; ``` -2. Apply the `Title` attribute to a controller method. The `Title` attribute takes a string as its first argument, which is the title you want to set for the page when this method is executed. +### Basic usage ```php #[Title('My Page Title')] -public function myMethod() +public function __invoke(): Response +{ + // ... +} +``` + +Output: `My Page Title - My Application` + +The title string is passed through the translator, so translation keys work: + +```yaml +# translations/messages.en.yaml +page.my_page: 'My Page Title' +``` + +```php +#[Title('page.my_page')] +public function __invoke(): Response +{ + // ... +} +``` + +### With a parent route + +Pass `['name' => 'route_name']` to append the parent route's title to the chain: + +```php +#[Title('Detail', ['name' => 'overview_route'])] +public function __invoke(): Response { - // Your code here + // ... } ``` -3. If you want the title to be extended with the parent's title, you can pass a second argument to the `Title` attribute. This argument should be an array with a `name` key that corresponds to the route name of the parent. +Output: `Detail - Overview - My Application` + +The parent chain is resolved recursively: if the parent route also has a `#[Title]` with its own parent, that is included too. + +### Dynamic titles + +Reference a controller argument by name using `{param}`: ```php -#[Title('My Page Title', ['name' => 'parent_route'])] -public function myMethod() +#[Title('Edit {name}')] +public function __invoke(string $name): Response { - // Your code here + // ... +} +``` + +Access a property of an object argument using `{object.property}`: + +```php +#[Title('{blog.title}')] +public function __invoke( + #[MapEntity(mapping: ['slug' => 'slug'])] + Blog $blog, +): Response { + // ... } ``` -4. If you want to prevent the title from being extended with the parent's title, you can pass a third argument to the `Title` attribute. This argument should be a boolean that indicates whether the title should be extended (`true`) or not (`false`). +Dynamic parameters are resolved from the named controller arguments. If a placeholder is not found, an exception is thrown. + +### Disable automatic appending + +Pass `extend: false` to set the title verbatim, with no translation, no parent chain, and no site title appended: ```php -#[Title('My Page Title', ['name' => 'parent_route'], false)] -public function myMethod() +#[Title('Exact Title', extend: false)] +public function __invoke(): Response { - // Your code here + // ... } ``` -5. The `Title` attribute can also handle dynamic titles. If you want to include a parameter in the title, you can do so by including it in curly braces `{}` in the title string. The parameter should be available in the request attributes. +Output: `Exact Title` + +## Using `PageTitle` directly + +Inject `PageTitle` to set or get the title from a service or Twig template: ```php -#[Title('My Page Title for {id}')] -public function myMethod($id) +use SumoCoders\FrameworkCoreBundle\Service\PageTitle; + +class MyService { - // Your code here + public function __construct(private PageTitle $pageTitle) {} + + public function doSomething(): void + { + $this->pageTitle->setTitle('Custom Title'); + } } ``` -6. The `TitleListener` class will automatically handle the `Title` attribute. It listens to the kernel controller event, fetches the `Title` attribute from the controller method being executed, and sets the page title accordingly. +In Twig, `PageTitle` is available as a string (via `__toString`): -Remember to clear the Symfony cache after adding or changing attributes, as Symfony compiles and caches the attributes when the cache is built. You can clear the cache by running `bin/console cache:clear` in your terminal. +```twig +{{ pageTitle }} +``` diff --git a/src/EventListener/BreadcrumbListener.php b/src/EventListener/BreadcrumbListener.php index cef5fd6..a5dc1a8 100644 --- a/src/EventListener/BreadcrumbListener.php +++ b/src/EventListener/BreadcrumbListener.php @@ -2,86 +2,65 @@ namespace SumoCoders\FrameworkCoreBundle\EventListener; -use Doctrine\ORM\EntityManagerInterface; use SumoCoders\FrameworkCoreBundle\Exception\Breadcrumb\EntityNotFoundException; use SumoCoders\FrameworkCoreBundle\ValueObject\Route; -use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use SumoCoders\FrameworkCoreBundle\Service\BreadcrumbTrail; use SumoCoders\FrameworkCoreBundle\ValueObject\Breadcrumb; use SumoCoders\FrameworkCoreBundle\Attribute\Breadcrumb as BreadcrumbAttribute; -use Symfony\Component\HttpKernel\Event\KernelEvent; -use Symfony\Component\Routing\RouterInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\Routing\RouterInterface; use InvalidArgumentException; use RuntimeException; use Symfony\Contracts\Translation\TranslatorInterface; class BreadcrumbListener { - private RouterInterface $router; - private PropertyAccessorInterface $propertyAccess; - private BreadcrumbTrail $breadcrumbTrail; - private Request $request; - private EntityManagerInterface $manager; - private TranslatorInterface $translator; - public function __construct( - RouterInterface $router, - PropertyAccessorInterface $propertyAccess, - BreadcrumbTrail $breadcrumbTrail, - EntityManagerInterface $manager, - TranslatorInterface $translator + private readonly RouterInterface $router, + private readonly PropertyAccessorInterface $propertyAccess, + private readonly BreadcrumbTrail $breadcrumbTrail, + private readonly TranslatorInterface $translator, ) { - $this->router = $router; - $this->propertyAccess = $propertyAccess; - $this->breadcrumbTrail = $breadcrumbTrail; - $this->manager = $manager; - $this->translator = $translator; } - public function onKernelController(KernelEvent $event): void + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void { - $controller = $event->getController(); - $this->request = $event->getRequest(); - - if (is_array($controller)) { - $controller = $controller[0]; - } - if ($event->isMainRequest()) { $this->breadcrumbTrail->reset(); } - $this->processBreadcrumbs($controller); - } + $controller = $event->getController(); - private function processBreadcrumbs(object $controller): void - { - // Build a new ReflectionClass instance of our controller - $class = new \ReflectionClass($controller); + if (is_array($controller)) { + [$object, $methodName] = $controller; + $class = new \ReflectionClass($object); + $method = $class->getMethod($methodName); + } elseif (is_object($controller) && !$controller instanceof \Closure) { + $class = new \ReflectionClass($controller); + $method = $class->getMethod('__invoke'); + } else { + return; + } if ($class->isAbstract()) { throw new InvalidArgumentException( - sprintf( - 'Attributes from class "%s" cannot be read as it is abstract.', - $class - ) + sprintf('Attributes from class "%s" cannot be read as it is abstract.', $class->getName()) ); } - $methods = $class->getMethods(\ReflectionMethod::IS_PUBLIC); - - foreach ($methods as $method) { - $this->processAttributeFromMethod($method, $class); - } + $this->processAttributeFromMethod($method, $class, $event->getNamedArguments(), $event->getRequest()); } + /** @param array $namedArguments */ private function processAttributeFromMethod( - \Reflectionmethod $method, + \ReflectionMethod $method, \ReflectionClass $class, - ?Route $route = null + array $namedArguments, + Request $request, + ?Route $route = null, ): void { $attributes = $method->getAttributes(BreadcrumbAttribute::class, \ReflectionAttribute::IS_INSTANCEOF); if ($method->name === '__invoke' || $attributes !== []) { @@ -97,30 +76,29 @@ private function processAttributeFromMethod( } if ($attributeInstance->hasParent()) { - $this->addBreadcrumbsForParent($attributeInstance->getParent()); + $this->addBreadcrumbsForParent($attributeInstance->getParent(), $namedArguments, $request); } try { $this->breadcrumbTrail->add( - $this->generateBreadcrumb( - $attributeInstance, - $method - ) + $this->generateBreadcrumb($attributeInstance, $namedArguments, $request) ); } catch (EntityNotFoundException $e) { - } } } + /** + * @param array $namedArguments + */ private function generateBreadcrumb( BreadcrumbAttribute $breadcrumb, - \Reflectionmethod $method + array $namedArguments, + Request $request, ): Breadcrumb { $title = $breadcrumb->getTitle(); $parameters = $breadcrumb->getParameters(); - // We're dealing with an expression, e.g. {item.name} if ($title[0] === '{' && $title[-1] === '}') { $expression = substr($title, 1, strlen($title) - 2); @@ -132,44 +110,18 @@ private function generateBreadcrumb( $attributeName = $expression; } - if (!$this->request->attributes->has($attributeName)) { + if (!array_key_exists($attributeName, $namedArguments)) { throw new RuntimeException( 'You tried to use {' . $attributeName . '} as a breadcrumb parameter, but there is no ' . 'parameter with that name in the route.' ); } - $attributeId = $this->request->attributes->get($attributeName); - - $name = null; - $mapping = null; - foreach ($method->getParameters() as $parameter) { - if ($parameter->name === $attributeName) { - $name = $parameter->getType()->getName(); - foreach ($parameter->getAttributes() as $attribute) { - if ($attribute->getName() === MapEntity::class) { - $mapping = $attribute->newInstance()->mapping; - } - } - } - } - - if ($name === null) { - throw new RuntimeException( - 'You tried to use {' . $attributeName . '} as a breadcrumb parameter, but there is no ' . - 'parameter with that name in the route.' - ); - } - - if ($mapping !== null && isset($mapping[$attributeName])) { - $attribute = $this->manager->getRepository($name)->findOneBy([$mapping[$attributeName] => $attributeId]); - } else { - $attribute = $this->manager->getRepository($name)->find($attributeId); - } + $attribute = $namedArguments[$attributeName]; if (!is_object($attribute)) { throw new EntityNotFoundException( - 'Could not resolve entity ' . $name . ' with ID ' . $attributeId + 'Could not resolve entity for parameter ' . $attributeName ); } @@ -184,7 +136,7 @@ private function generateBreadcrumb( } if ($breadcrumb->hasRoute()) { - $this->resolveRouteParameters($breadcrumb); + $this->resolveRouteParameters($breadcrumb, $namedArguments, $request); return new Breadcrumb( $title, @@ -206,34 +158,18 @@ private function generateBreadcrumb( $attributeName = $parameterValue; } - if (!$this->request->attributes->has($attributeName)) { - throw new RuntimeException( - 'You tried to use {' . $attributeName . '} as a breadcrumb parameter, but there is no ' . - 'parameter with that name in the route.' - ); - } - - $attributeId = $this->request->attributes->get($attributeName); - - $name = null; - foreach ($method->getParameters() as $parameter) { - if ($parameter->name === $attributeName) { - $name = $parameter->getType()->getName(); - } - } - - if ($name === null) { + if (!array_key_exists($attributeName, $namedArguments)) { throw new RuntimeException( 'You tried to use {' . $attributeName . '} as a breadcrumb parameter, but there is no ' . 'parameter with that name in the route.' ); } - $attribute = $this->manager->getRepository($name)->find($attributeId); + $attribute = $namedArguments[$attributeName]; if (!is_object($attribute)) { throw new RuntimeException( - 'Could not resolve entity ' . $name . ' with ID ' . $attributeId + 'Could not resolve entity for parameter ' . $attributeName ); } @@ -250,11 +186,11 @@ private function generateBreadcrumb( $title = $this->translator->trans($title, $parameters); } - // Just a simple string return new Breadcrumb($title); } - private function addBreadcrumbsForParent(Route $parent): void + /** @param array $namedArguments */ + private function addBreadcrumbsForParent(Route $parent, array $namedArguments, Request $request): void { $routeName = $parent->getName(); $routeInformation = $this->getRouteInformation($routeName); @@ -265,59 +201,36 @@ private function addBreadcrumbsForParent(Route $parent): void ); } - // If class contains :: in the name, we're dealing with a static method - if (strpos($routeInformation['controller'], '::') > 0) { - $parts = explode('::', $routeInformation['controller']); - $class = new \ReflectionClass($parts[0]); - - $method = $class->getMethod($parts[1]); - } else { - $class = new \ReflectionClass($routeInformation['controller']); - $method = $class->getMethod($routeInformation['method']); - } + $class = new \ReflectionClass($routeInformation['controller']); + $method = $class->getMethod($routeInformation['method']); - $this->processAttributeFromMethod($method, $class, new Route($routeName)); + $this->processAttributeFromMethod($method, $class, $namedArguments, $request, new Route($routeName)); } + /** @return array|null */ private function getRouteInformation(string $name): ?array { - // Get all the routes defined in the entire application $routes = $this->router->getRouteCollection()->all(); foreach ($routes as $key => $route) { - // Get our canonical (without a locale prefixed) route name if ($route->getDefault('_canonical_route') !== $name && $key !== $name) { continue; } - /* - * In the case of multiple methods defined per controller, - * explode the controller name and method - */ - if (strpos($route->getDefault('_controller'), '::') > 0) { - $chunk = explode('::', $route->getDefault('_controller')); - $controller = $chunk[0]; - $method = $chunk[1]; - } else { - $controller = $route->getDefault('_controller'); - } + $controller = $route->getDefault('_controller'); + $hasMethod = strpos($controller, '::') > 0; + $controllerClass = $hasMethod ? explode('::', $controller)[0] : $controller; + $method = $hasMethod ? explode('::', $controller)[1] : '__invoke'; - // Compile the route to access the parameters $compiledRoute = $route->compile(); - $parameters = $compiledRoute->getVariables(); - - // Loop each parameter and check if a default exists for it - $requiredParameters = []; - foreach ($parameters as $parameter) { - if ($route->getDefault($parameter) === null) { - $requiredParameters[] = $parameter; - } - } + $requiredParameters = array_filter( + $compiledRoute->getVariables(), + fn($parameter) => $route->getDefault($parameter) === null + ); - // Return the controller, method and required parameters return [ - 'controller' => $controller, - 'method' => $method ?? '__invoke', + 'controller' => $controllerClass, + 'method' => $method, 'parameters' => $requiredParameters, ]; } @@ -325,38 +238,27 @@ private function getRouteInformation(string $name): ?array return null; } - private function resolveRouteParameters(BreadcrumbAttribute $breadcrumb): void + /** @param array $namedArguments */ + private function resolveRouteParameters(BreadcrumbAttribute $breadcrumb, array $namedArguments, Request $request): void { $route = $breadcrumb->getRoute(); $routeInformation = $this->getRouteInformation($route->getName()); $requiredParameters = $routeInformation['parameters']; $parentParameters = []; - $currentAttributes = $this->request->attributes->all(); foreach ($requiredParameters as $requiredParentParameter) { - /* - * In real world scenario's, the parent is often present - * in the same URI as the request. Take for example: - * /{item}/{child} - * If we're currently in the child route, we can check the URI - * for the author parameter and already fill it in. - */ - if (\array_key_exists($requiredParentParameter, $currentAttributes)) { - if (is_object($currentAttributes[$requiredParentParameter])) { - $parentParameters[$requiredParentParameter] = $currentAttributes[$requiredParentParameter]->getId(); - } else { - $parentParameters[$requiredParentParameter] = $currentAttributes[$requiredParentParameter]; - } + if (array_key_exists($requiredParentParameter, $namedArguments)) { + $value = $namedArguments[$requiredParentParameter]; + $parentParameters[$requiredParentParameter] = is_object($value) ? $value->getId() : $value; + } elseif ($request->attributes->has($requiredParentParameter)) { + $parentParameters[$requiredParentParameter] = $request->attributes->get($requiredParentParameter); } } $route->addParameters($parentParameters); - if ( - count($routeInformation['parameters']) > 0 - && !$route->getParameters() - ) { + if (count($routeInformation['parameters']) > 0 && !$route->getParameters()) { throw new RuntimeException( 'Your breadcrumb route is missing required parameters: ' . implode($routeInformation['parameters']) @@ -364,7 +266,7 @@ private function resolveRouteParameters(BreadcrumbAttribute $breadcrumb): void } foreach ($routeInformation['parameters'] as $requiredParameter) { - if (!\array_key_exists($requiredParameter, $route->getParameters())) { + if (!array_key_exists($requiredParameter, $route->getParameters())) { throw new RuntimeException( 'Your breadcrumb route is missing required parameters: ' . $requiredParameter ); diff --git a/src/EventListener/TitleListener.php b/src/EventListener/TitleListener.php index 30305d2..57c2811 100644 --- a/src/EventListener/TitleListener.php +++ b/src/EventListener/TitleListener.php @@ -2,24 +2,16 @@ namespace SumoCoders\FrameworkCoreBundle\EventListener; -use ReflectionClass; +use ReflectionMethod; use SumoCoders\FrameworkCoreBundle\Attribute\Title; use SumoCoders\FrameworkCoreBundle\Service\Fallbacks; use SumoCoders\FrameworkCoreBundle\Service\PageTitle; use SumoCoders\FrameworkCoreBundle\ValueObject\Route; -use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\RouterInterface; use Symfony\Contracts\Translation\TranslatorInterface; -use Doctrine\ORM\EntityManagerInterface; - -/** - * Class TitleListener - * - * This class is responsible for handling the title of the page. - * It listens to the kernel controller event and sets the title based on the Title attribute. - */ + class TitleListener { public function __construct( @@ -27,85 +19,53 @@ public function __construct( private Fallbacks $fallbacks, private RouterInterface $router, private TranslatorInterface $translator, - private EntityManagerInterface $manager, private PropertyAccessorInterface $propertyAccess, ) { } - /** - * Event listener for the kernel controller event. - * - * @param KernelEvent $event - */ - public function onKernelController(KernelEvent $event): void + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void { - // Get the controller and its methods - $controller = is_array($event->getController()) ? $event->getController()[0] : $event->getController(); - $methods = (new ReflectionClass($controller))->getMethods(); - - // Loop through the methods and process the Title attributes - foreach ($methods as $method) { - $attributes = $method->getAttributes(Title::class, \ReflectionAttribute::IS_INSTANCEOF); - - if (empty($attributes)) { - continue; - } + $method = $this->resolveMethod($event->getController()); + if ($method === null) { + return; + } - // Process the parameters of the method - $parameters = $this->processParameters($method->getParameters(), $event->getRequest()->attributes->all()); + $attributes = $method->getAttributes(Title::class, \ReflectionAttribute::IS_INSTANCEOF); + if (empty($attributes)) { + return; + } - // Loop through the Title attributes and set the page title - foreach ($attributes as $attribute) { - $titleAttribute = $attribute->newInstance(); + $parameters = $event->getNamedArguments(); - if (!$titleAttribute->isExtend()) { - $this->pageTitleService->setTitle($titleAttribute->getTitle()); - return; - } + foreach ($attributes as $attribute) { + $titleAttribute = $attribute->newInstance(); - $title = $this->processTitle($titleAttribute->getTitle(), $parameters); + if (!$titleAttribute->isExtend()) { + $this->pageTitleService->setTitle($titleAttribute->getTitle()); + return; + } - if ($titleAttribute->hasParent()) { - $title .= $this->getTitleFromParent($titleAttribute->getParent(), $parameters); - } + $title = $this->processTitle($titleAttribute->getTitle(), $parameters); - $this->pageTitleService->setTitle($title . ' - ' . $this->fallbacks->get('site_title')); + if ($titleAttribute->hasParent()) { + $title .= $this->getTitleFromParent($titleAttribute->getParent(), $parameters); } + + $this->pageTitleService->setTitle($title . ' - ' . $this->fallbacks->get('site_title')); } } - /** - * Process the parameters of a method. - * - * @param array<\ReflectionParameter> $reflextionParameters - * @param array $parameters - * @return array - */ - private function processParameters(array $reflextionParameters, array $parameters): array + private function resolveMethod(mixed $controller): ?ReflectionMethod { - // Loop through the reflection parameters and process the MapEntity attributes - foreach ($reflextionParameters as $reflextionParameter) { - $parameterName = $reflextionParameter->getName(); - - if (!array_key_exists($parameterName, $parameters)) { - continue; - } - - $parameterAttributes = $reflextionParameter->getAttributes(MapEntity::class); - if (empty($parameterAttributes)) { - continue; - } - - // Get the mapping and value of the parameter - $mapping = $parameterAttributes[0]->getArguments()['mapping'] ?? null; - $value = $mapping !== null && isset($parameters[$parameterName]) - ? $this->manager->getRepository($reflextionParameter->getType()->getName())->findOneBy([$mapping[$parameterName] => $parameters[$parameterName]]) - : $this->manager->getRepository($reflextionParameter->getType()->getName())->find($parameters[$parameterName]); + if (is_array($controller)) { + return new ReflectionMethod($controller[0], $controller[1]); + } - $parameters[$parameterName] = $value; + if (is_object($controller) && !$controller instanceof \Closure) { + return new ReflectionMethod($controller, '__invoke'); } - return $parameters; + return null; } /** @@ -182,13 +142,15 @@ private function getRouteInformation(string $name): ?array // Get the controller and method of the route $controller = $route->getDefault('_controller'); - $method = strpos($controller, '::') > 0 ? explode('::', $controller)[1] : '__invoke'; + $hasMethod = strpos($controller, '::') > 0; + $controllerClass = $hasMethod ? explode('::', $controller)[0] : $controller; + $method = $hasMethod ? explode('::', $controller)[1] : '__invoke'; // Get the required parameters of the route $requiredParameters = array_filter($route->compile()->getVariables(), fn($parameter) => $route->getDefault($parameter) === null); return [ - 'controller' => $controller, + 'controller' => $controllerClass, 'method' => $method, 'parameters' => $requiredParameters, ]; diff --git a/templates/base.html.twig b/templates/base.html.twig index db683a9..0a3577d 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -7,10 +7,65 @@ or has_header_actions_center|trim is not empty or has_header_actions_right|trim is not empty %} + + + + + + + {% block title %} + {{ page_title }} + {% endblock %} + -{% block head %} - {{ include('@SumoCodersFrameworkCore/head.html.twig', {title: block('title') is defined ? block('title') : page_title}) }} -{% endblock %} + {% block meta %} + {# Call both script and style so both nonces are generated #} + {% set csp_nonce = csp_nonce('script') %} + {% set csp_nonce = csp_nonce('style') %} + + + {% endblock %} + + {% block stylesheets %} + + {% endblock %} + + {% block javascripts %} + {{ importmap('app', {'nonce': csp_nonce('script')}) }} + {% endblock %} + + {% block icons %} + + + + {% endblock %} + + {% block favicon %} + + + {% endblock %} + + {% block windows_tile %} + {# Windows 8 tile #} + + + + {% endblock %} + + {% block og %} + + {% endblock %} + + {% block twitter %} + {# Twitter #} + + + + {% endblock %} + + {% block end_head %} + {% endblock %} + {{ include('@SumoCodersFrameworkCore/settheme.html.twig') }} diff --git a/templates/base_error.html.twig b/templates/base_error.html.twig index 3b7af41..440ec4c 100644 --- a/templates/base_error.html.twig +++ b/templates/base_error.html.twig @@ -1,11 +1,63 @@ -{% block head %} - {% include '@SumoCodersFrameworkCore/head.html.twig' with {title: block('title') is defined ? block('title') : page_title} %} + + + + + + + + {% block title %} + {{ page_title }} + {% endblock %} + + + {% block meta %} + {# Call both script and style so both nonces are generated #} + {% set csp_nonce = csp_nonce('script') %} + {% set csp_nonce = csp_nonce('style') %} + + + {% endblock %} {% block stylesheets %} {% endblock %} -{% endblock %} + {% block javascripts %} + {{ importmap('app', {'nonce': csp_nonce('script')}) }} + {% endblock %} + + {% block icons %} + + + + {% endblock %} + + {% block favicon %} + + + {% endblock %} + + {% block windows_tile %} + {# Windows 8 tile #} + + + + {% endblock %} + + {% block og %} + + {% endblock %} + + {% block twitter %} + {# Twitter #} + + + + {% endblock %} + + {% block end_head %} + {% endblock %} + {{ include('@SumoCodersFrameworkCore/settheme.html.twig') }} diff --git a/templates/base_no_sidebar.html.twig b/templates/base_no_sidebar.html.twig index 7571149..076429d 100644 --- a/templates/base_no_sidebar.html.twig +++ b/templates/base_no_sidebar.html.twig @@ -1,6 +1,63 @@ -{% block head %} - {% include '@SumoCodersFrameworkCore/head.html.twig' with {title: block('title') is defined ? block('title') : page_title} %} -{% endblock %} + + + + + + + + {% block title %} + {{ page_title }} + {% endblock %} + + + {% block meta %} + {# Call both script and style so both nonces are generated #} + {% set csp_nonce = csp_nonce('script') %} + {% set csp_nonce = csp_nonce('style') %} + + + {% endblock %} + + {% block stylesheets %} + + {% endblock %} + + {% block javascripts %} + {{ importmap('app', {'nonce': csp_nonce('script')}) }} + {% endblock %} + + {% block icons %} + + + + {% endblock %} + + {% block favicon %} + + + {% endblock %} + + {% block windows_tile %} + {# Windows 8 tile #} + + + + {% endblock %} + + {% block og %} + + {% endblock %} + + {% block twitter %} + {# Twitter #} + + + + {% endblock %} + + {% block end_head %} + {% endblock %} + {{ include('@SumoCodersFrameworkCore/settheme.html.twig') }} diff --git a/templates/empty.html.twig b/templates/empty.html.twig index f71af79..b14c326 100644 --- a/templates/empty.html.twig +++ b/templates/empty.html.twig @@ -1,6 +1,63 @@ -{% block head %} - {% include '@SumoCodersFrameworkCore/head.html.twig' with {title: block('title') is defined ? block('title') : page_title} %} -{% endblock %} + + + + + + + + {% block title %} + {{ page_title }} + {% endblock %} + + + {% block meta %} + {# Call both script and style so both nonces are generated #} + {% set csp_nonce = csp_nonce('script') %} + {% set csp_nonce = csp_nonce('style') %} + + + {% endblock %} + + {% block stylesheets %} + + {% endblock %} + + {% block javascripts %} + {{ importmap('app', {'nonce': csp_nonce('script')}) }} + {% endblock %} + + {% block icons %} + + + + {% endblock %} + + {% block favicon %} + + + {% endblock %} + + {% block windows_tile %} + {# Windows 8 tile #} + + + + {% endblock %} + + {% block og %} + + {% endblock %} + + {% block twitter %} + {# Twitter #} + + + + {% endblock %} + + {% block end_head %} + {% endblock %} + {{ include('@SumoCodersFrameworkCore/settheme.html.twig') }} diff --git a/templates/head.html.twig b/templates/head.html.twig deleted file mode 100644 index c177e97..0000000 --- a/templates/head.html.twig +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - {{ title }} - - {% block meta %} - {# Call both script and style so both nonces are generated #} - {% set csp_nonce = csp_nonce('script') %} - {% set csp_nonce = csp_nonce('style') %} - - - {% endblock %} - - {% block stylesheets %} - - {% endblock %} - - {% block javascripts %} - {{ importmap('app', {'nonce': csp_nonce('script')}) }} - {% endblock %} - - {% block icons %} - - - - {% endblock %} - - {% block favicon %} - - - {% endblock %} - - {% block windows_tile %} - {# Windows 8 tile #} - - - - {% endblock %} - - {% block og %} - - {% endblock %} - - {% block twitter %} - {# Twitter #} - - - - {% endblock %} - - {% block end_head %} - {% endblock %} - diff --git a/templates/user.html.twig b/templates/user.html.twig index 1a673e1..dc90962 100644 --- a/templates/user.html.twig +++ b/templates/user.html.twig @@ -1,6 +1,63 @@ -{% block head %} - {% include '@SumoCodersFrameworkCore/head.html.twig' with {title: block('title') is defined ? block('title') : page_title} %} -{% endblock %} + + + + + + + + {% block title %} + {{ page_title }} + {% endblock %} + + + {% block meta %} + {# Call both script and style so both nonces are generated #} + {% set csp_nonce = csp_nonce('script') %} + {% set csp_nonce = csp_nonce('style') %} + + + {% endblock %} + + {% block stylesheets %} + + {% endblock %} + + {% block javascripts %} + {{ importmap('app', {'nonce': csp_nonce('script')}) }} + {% endblock %} + + {% block icons %} + + + + {% endblock %} + + {% block favicon %} + + + {% endblock %} + + {% block windows_tile %} + {# Windows 8 tile #} + + + + {% endblock %} + + {% block og %} + + {% endblock %} + + {% block twitter %} + {# Twitter #} + + + + {% endblock %} + + {% block end_head %} + {% endblock %} + {{ include('@SumoCodersFrameworkCore/settheme.html.twig') }}