prestaconcept / PrestaSitemapBundle

A symfony bundle that provides tools to build a rich application sitemap. The main goals are : simple, no databases, various namespace (eg. google image), respect constraints etc.
MIT License
347 stars 100 forks source link

How to generate sitemap with required parameter (locale) inside route annotations? #272

Closed kdefives closed 3 years ago

kdefives commented 3 years ago

Hello,

Context:

To do that, below how my controller is declared:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class CookieConsentController extends AbstractController
{

    /**
     *
     * @Route(
     *     "/{_locale}/cookie-preferences",
     *     name="cookie.display",
     *     requirements={
     *         "_locale": "%app.supported_locales%",
     *     },
     *     methods={"GET", "POST"},
     *     options={"sitemap" = {"priority" = 0.3, "changefreq" = "monthly" }}
     * )
     *
     * @return Response
     */
    public function display() : Response
    {
        return $this->render('cookie/display.html.twig');
    }
}

Below the declaration of app.supported_locales parameter in services.yaml:

parameters:
    app.supported_locales: 'en|fr'

Problem: When i execute the command to dump the sitemap, i have an error message:

root@878cf08d81f1:/var/www# php bin/console presta:sitemaps:dump
Dumping all sections of sitemaps into /var/www/public directory

In RouteAnnotationEventListener.php line 164:

  Invalid argument for route "cookie.display": The route "cookie.display" cannot have the sitemap option because it requires parameters other than ""  

In RouteAnnotationEventListener.php line 189:

  The route "cookie.display" cannot have the sitemap option because it requires parameters other than ""  

In UrlGenerator.php line 177:

  Some mandatory parameters are missing ("_locale") to generate a URL for route "cookie.display".  

presta:sitemaps:dump [--section SECTION] [--base-url BASE-URL] [--gzip] [--] [<target>]

So i tried to execute this command to try to give the required parameter but it the same:

root@878cf08d81f1:/var/www# php bin/console presta:sitemaps:dump public/sitemap/fr/ --base-url=http://valoperf.com/fr
Dumping all sections of sitemaps into public/sitemap/fr directory

In RouteAnnotationEventListener.php line 164:

  Invalid argument for route "cookie.display": The route "cookie.display" cannot have the sitemap option because it requires parameters other than ""  

In RouteAnnotationEventListener.php line 189:

  The route "cookie.display" cannot have the sitemap option because it requires parameters other than ""  

In UrlGenerator.php line 177:

  Some mandatory parameters are missing ("_locale") to generate a URL for route "cookie.display".  

presta:sitemaps:dump [--section SECTION] [--base-url BASE-URL] [--gzip] [--] [<target>]

Questions: Could I use the concept of "dynamic route usage" as a solution? I mean, using Subscriber instead of route annotations? In this case, how to determine the supported sitemap parameters (mastmod, changefreq and priority)?

Thanks for your help

kdefives commented 3 years ago

I find a solution by creating a SitemapSubscriber as below, if it can help other people who face the same problem, feel free to reuse and/or adapt the code:

<?php

namespace App\EventSubscriber;

use App\EventSubscriber\Model\RouteSiteMapped;
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
use Presta\SitemapBundle\Service\UrlContainerInterface;
use Presta\SitemapBundle\Sitemap\Url\UrlConcrete;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
 * Class SitemapSubscriber
 * @package App\EventSubscriber
 *
 * This subscriber is used to dump/generate the sitemap.
 */
class SitemapSubscriber implements EventSubscriberInterface
{
    /**
     * @var array<RouteSiteMapped>
     */
    private array $routesToAddToSitemap;

    /**
     * @var UrlGeneratorInterface
     */
    private UrlGeneratorInterface $urlGenerator;

    /**
     * @var array<string>
     */
    private array $supportedLocales;

    /**
     * SitemapSubscriber constructor.
     * @param UrlGeneratorInterface $urlGenerator
     * @param string $supportedLocales
     */
    public function __construct(UrlGeneratorInterface $urlGenerator, string $supportedLocales)
    {
        $this->urlGenerator = $urlGenerator;
        $this->supportedLocales = explode('|', $supportedLocales);

        // Initialise routes will be included inside sitemap
        $this->initRoutesToAddToSitemap();
    }

    /**
     * @return array<string, string>
     */
    public static function getSubscribedEvents(): array
    {
        return [
            SitemapPopulateEvent::ON_SITEMAP_POPULATE => 'populate',
        ];
    }

    public function populate(SitemapPopulateEvent $event): void
    {
        $this->registerStaticPages($event->getUrlContainer());
    }

    /**
     * Initialized routes that will be included inside sitemap.
     *
     * Note: Add routes in this function to add a route inside sitemap.
     */
    private function initRoutesToAddToSitemap(): void
    {
        // Add homepage
        $this->routesToAddToSitemap[] = new RouteSiteMapped('homepage.display',
            null, RouteSiteMapped::CHANGE_FREQ_WEEKLY, 1);

        // Add aboutus page
        $this->routesToAddToSitemap[] = new RouteSiteMapped('about.display',
            null, RouteSiteMapped::CHANGE_FREQ_WEEKLY, 0.5);

        // Add privacy policy page
        $this->routesToAddToSitemap[] = new RouteSiteMapped('privacyPolicy.display',
            null, RouteSiteMapped::CHANGE_FREQ_WEEKLY, 0.5);

        // Add cookie consent page
        $this->routesToAddToSitemap[] = new RouteSiteMapped('cookie.display',
            null, RouteSiteMapped::CHANGE_FREQ_MONTHLY, 0.3);
    }

    /**
     * @param UrlContainerInterface $urls
     */
    private function registerStaticPages(UrlContainerInterface $urls): void
    {
        // Loop all available locales
        foreach ($this->supportedLocales as $locale)
        {
            // Loop all routes that we want to add inside sitemap
            foreach ($this->routesToAddToSitemap as $routeSiteMapped)
            {
                $urls->addUrl(
                    new UrlConcrete(
                        $this->urlGenerator->generate(
                            $routeSiteMapped->getRouteName(),
                            ['_locale' => $locale],
                            UrlGeneratorInterface::ABSOLUTE_URL
                        ),
                        $routeSiteMapped->getLastmod(),
                        $routeSiteMapped->getChangefreq(),
                        $routeSiteMapped->getPriority()
                    ),
                    $routeSiteMapped->getSection()
                );
            }
        }
    }
}

the code above this this:

# services.yaml
parameters:
    app.supported_locales: 'en|fr'

    router.request_context.host: 'my-website.com'
    router.request_context.scheme: 'http'

services:
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
        bind:
            $supportedLocales: '%app.supported_locales%'
<?php

namespace App\EventSubscriber\Model;

use DateTimeInterface;

/**
 * Class RouteSiteMapped
 * @package App\EventSubscriber\Model
 *
 * This class is a representation of route which will be includes inside generated sitemap.
 */
class RouteSiteMapped
{
    /**
     * Available values for changefreq used in sitemap.
     */
    const CHANGE_FREQ_WEEKLY = 'weekly';
    const CHANGE_FREQ_MONTHLY = 'monthly';

    /**
     * @var string The route name (cf. controller annotation).
     */
    private string $routeName;

    /**
     * @var DateTimeInterface|null The data of last modification used in sitemap.
     */
    private ?DateTimeInterface $lastmod;

    /**
     * @var string|null The change frequency value used in sitemap.
     */
    private ?string $changefreq;

    /**
     * @var float|null The priority of the url used in sitemap.
     */
    private ?float $priority;

    /**
     * @var string The section used in sitemap for the url.
     */
    private string $section;

    /**
     * RouteSiteMapped constructor.
     * @param string $routeName
     * @param DateTimeInterface|null $lastmod
     * @param string|null $changefreq
     * @param float|null $priority
     * @param string $section
     */
    public function __construct(string $routeName,
                                ?DateTimeInterface $lastmod,
                                ?string $changefreq,
                                ?float $priority,
                                string $section = 'default')
    {
        $this->routeName = $routeName;
        $this->lastmod = $lastmod;
        $this->changefreq = $changefreq;
        $this->priority = $priority;
        $this->section = $section;
    }

    /**
     * @return string
     */
    public function getRouteName(): string
    {
        return $this->routeName;
    }

    /**
     * @return DateTimeInterface|null
     */
    public function getLastmod(): ?DateTimeInterface
    {
        return $this->lastmod;
    }

    /**
     * @return string|null
     */
    public function getChangefreq(): ?string
    {
        return $this->changefreq;
    }

    /**
     * @return float|null
     */
    public function getPriority(): ?float
    {
        return $this->priority;
    }

    /**
     * @return string
     */
    public function getSection(): string
    {
        return $this->section;
    }
}
yann-eugone commented 3 years ago

Hey ! Sorry I'm late...

You're right, the bundle register automatically routes that has no parameters, because this is not possible to guess what are possible values.

So you are right, for these kind of routes, you need to register a listener that will stick to your requirements/convention.


But in your case, we may have something that got you covered. Some time ago, someone wanted to automatically register alternates for translated routes. We ended with this solution : https://github.com/prestaconcept/PrestaSitemapBundle/pull/251 But sadly it is not documented yet (I wrote https://github.com/prestaconcept/PrestaSitemapBundle/issues/256 to remember).

Maybe you will find some inspiration here if you want to try something else.

kdefives commented 3 years ago

Thank you @yann-eugone Interesting! Does it means I just have to declare this config and my locales value will be populated automatically?

presta_sitemap:
    alternate:
        enabled: true
        default_locale: en
        locales: [en, fr]
        i18n: symfony
yann-eugone commented 3 years ago

This system was designed to work with Symfony routing internationalisation : https://symfony.com/blog/new-in-symfony-4-1-internationalized-routing

But you might try with this config and see what is happening 😉

But keep in mind that the result won't be the same : this will not register 2 routes at the root of you sitemap. You will have the route using default locale in the sitemap, and the alternates within this route.

kdefives commented 3 years ago

I tested with this config but i still have the same error. :-(

Your link is about Symfony 4.1 but it for my project i am using Symfony 5.2 and I followed this documentation to implement localizations: https://symfony.com/doc/current/the-fast-track/en/28-intl.html#internationalizing-urls

The advantage of using "_locale" parameter in route path instead of prefix is that I can save in session the current locale used, and also determine the prefered locale for the current user using function $request->getPreferredLanguage(...) when the user try to access to my website without specifying prefix (ie. https://mywebsite.com is redirecting to https://mywebsite.com/[prefered_local] using a custom LocalSubscriber (code below).

<?php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Class LocaleSubscriber
 * @package App\EventSubscriber
 *
 * This subscriber is managing the locale in User session and detect
 * the preferred language for the user during the first connection.
 */
class LocaleSubscriber implements EventSubscriberInterface
{

    private string $defaultLocale;

    /**
     * @var array<string>
     */
    private array $supportedLocales;

    /**
     * LocaleSubscriber constructor.
     * @param string $defaultLocale
     * @param string $supportedLocales
     */
    public function __construct(string $supportedLocales, string $defaultLocale = 'en')
    {
        $this->defaultLocale = $defaultLocale;
        $this->supportedLocales = explode('|', $supportedLocales);
    }

    /**
     * @param RequestEvent $event
     */
    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // If not previous session found
        if (!$request->hasPreviousSession()) {

            // try to see if the locale has been set as a _locale routing parameter
            if ($locale = $request->attributes->get('_locale'))
            {
                $request->getSession()->set('_locale', $locale);
            }else{

                // try to see if user have preferred language
                if($preferredLanguage = $request->getPreferredLanguage($this->supportedLocales))
                {
                    $request->getSession()->set('_locale', $preferredLanguage);

                    // Set the local with the prefered language
                    $request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
                }
            }
        }else{

            // Here, a session has been found
            // try to see if the locale has been set as a _locale routing parameter
            if ($locale = $request->attributes->get('_locale'))
            {
                $request->getSession()->set('_locale', $locale);
            } else {

                // if no explicit locale has been set on this request,
                // use one from the session
                $request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
            }
        }
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents()
    {
        return [
            // must be registered before (i.e. with a higher priority than) the default Locale listener
            KernelEvents::REQUEST => [['onKernelRequest', 20]],
        ];
    }
}

Maybe i did wrong, do not hesitate to tell me in that case. ^^'

yann-eugone commented 3 years ago

My link is just the news Symfony created when support of route internationalisation was introduced : it's working like this since 4.1.

What you did is prefixing URLs, which is fine, but it is not an internationalized routing system. It is just a prefix with a var, this is why it is not working.

As I said, I was not sure the system would be compatible with what you did, now we know : it is not. I think you can stick with your first solution, it will work just fine.

kdefives commented 3 years ago

Yes, thank you Yann. It is still interesting to see another solution. :) Thanks again and have a nice day! ;)