Accueil /  Blog / Symfony / Librarie Open Source : PrestaSitemapBundle

Librarie Open Source : PrestaSitemapBundle

Publié le vendredi 23 octobre 2020

Le bundle OpenSource made by Prestaconcept qui génère le sitemap de votre projet Symfony

Besoin

Avoir un sitemap est quelque chose de commun, il y a quelques années, c'était même un pré-requis pour accéder à un bon niveau de référencement (aujourd'hui c'est facultatif, puisque les robots d'indexation peuvent construire le plan en naviguant sur votre site).

Ainsi, dès lors que l'on construit une application avec une partie publique, faire un plan est toujours une bonne idée.

Problématique

Un sitemap n'est rien d'autre qu'un XML censé représenter au mieux la structure de votre site. C'est donc un fichier structuré, validé.

On peut l'écrire à la main. A l'initialisation, ça ne prendra pas beaucoup de temps (si l'application n'est pas trop grosse), mais il faudra le maintenir par la suite. Et ça, en revanche, ça sera chronophage et source d'erreurs.

Solution

Puisque ce XML obéit à des règles et qu'elles sont communes à tous les projets, nous devrions être en mesure de proposer une méthode pour générer ce fichier.

C'est là qu'est né ce bundle (même si à l'origine, c'était un plugin Symfony 1.x...).

La promesse est simple : intégrer à votre application Symfony une façon simple et efficace de générer votre plan de site, avec un effort moindre.

Vous pouvez sans attendre le retrouver sur GitHub ou Packagist .

note : Vous pouvez également utiliser le projet de démo pour tester le bundle : GitHub

Installation

Ajoutez le bundle en dépendance de votre projet.

composer require presta/sitemap-bundle

Si vous n'êtes pas encore passés sous symfony/flex (vous devriez y songer), ajoutez le bundle à votre kernel.

new Presta\SitemapBundle\PrestaSitemapBundle(),

Importez les routes du bundle.

PrestaSitemapBundle:
    resource: "@PrestaSitemapBundle/Resources/config/routing.yml"

Voilà, c'est installé, reste à configurer les routes que vous souhaitez exposer.

Cas d'usage

Imaginons que votre application ait quelques routes publiques :

  • / : la page d'accueil (merci capitaine)
  • /faq : une foire aux questions
  • /contact : un formulaire de contact
  • /offre/{offerSlug} : une page d'offre
  • /blog/{categorySlug} : une page de liste de billets de blog pour une catégorie donnée
  • /blog/{categorySlug}/{postDate}/{postSlug} : une page de billet de blog

Je pars du principe que votre application existe déjà, que vos contrôleurs et vos routes sont configurés, etc... Dans cet article nous n'allons détailler que les spécificités du bundle.

Routes statiques

Nous appelons "route statique" une route n'ayant aucun paramètre requis. Pour la construire, le nom de la route suffit.

class StaticController
{
    /**
     * @Route("/", name="homepage", options={"sitemap" = true})
     */
    public function homepageAction() { /* ... */ }

    /**
     * @Route("/faq", name="faq", options={"sitemap" = true})
     */
    public function faqAction() { /* ... */ }

    /**
     * @Route("/contact", name="contact", options={"sitemap" = true})
     */
    public function contactAction() { /* ... */ }
}

note : si vous n'aimez pas les annotations, sachez que vous pouvez configurer ces options en YAML ou en XML

En quelques caractères, on a déjà couvert les 3 premières routes de notre liste. Pour les routes statiques, il suffit de configurer l'option "sitemap" = true, et le tour est joué.

note : "sitemap" = true est un raccourci qui enregistre les options par défaut pour la route. Vous pouvez détailler toutes ces options si vous le souhaitez. Voir la documentation.

Vous pouvez déjà vous rendre sur /sitemap.default.xml et constater que vos pages sont présentes.

Routes dynamiques

Nous appelons "route dynamique" une route ayant au moins un paramètre requis. Pour la construire, il faut donner une valeur à chacun de ces paramètres, et vous seuls pouvez donner ces valeurs.

Comme souvent, vous allez vouloir stocker tout ça dans une base de données, probablement SQL et que vous allez certainement utiliser l'ORM Doctrine comme intermédiaire (je sais, j'ai un don).

class BlogController
{
    /**
     * @Route("/blog/{categorySlug}", name="blog-post-list-by-category")
     */
    public function listByCategoryAction(string $categorySlug) { /* ... */ }

    /**
     * @Route("/blog/{categorySlug}/{postDate}/{postSlug}", name="blog-post-detail")
     */
    public function detailAction(string $categorySlug, DateTimeInterface $categorySlug, string $postSlug) { /* ... */ }
}
class CommerceController
{
    /**
     * @Route("/offre/{offerSlug}", name="offer-detail")
     */
    public function offerAction(string $offerSlug) { /* ... */ }
}

note : ce bundle n'a aucun lien avec Doctrine ORM, ni avec les bases de données en général. Les paramètres des routes peuvent provenir de sources diverses et variées, mais le problème reste entier.

Pour alimenter notre sitemap avec nos routes dynamiques, nous allons utiliser un event subscriber.

use Doctrine\ORM\EntityRepository;
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
use Presta\SitemapBundle\Service\UrlContainerInterface;
use Presta\SitemapBundle\Sitemap\Url\UrlConcrete;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;

class SitemapListener implements EventSubscriberInterface
{
    private $doctrine;
    private $router;

    public function __construct(RegistryInterface $doctrine, RouterInterface $router)
    {
        $this->doctrine = $doctrine;
        $this->router = $router;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            SitemapPopulateEvent::ON_SITEMAP_POPULATE => 'buildSitemap',
        ];
    }

    public function buildSitemap(SitemapPopulateEvent $event): void
    {
        if (in_array($event->getSection(), ['default', null], true)) {
            $this->registerOffers($event->getUrlContainer());
        }
        if (in_array($event->getSection(), ['blog', null], true)) {
            $this->registerBlog($event->getUrlContainer());
        }
    }

    private function registerOffers(UrlContainerInterface $sitemap): void
    {
        foreach ($this->iterate(Offer::class) as $offer) {
            $sitemap->addUrl(
                $this->url(
                    'offer-detail',
                    ['offerSlug' => $offer->getSlug()],
                ),
                'default'
            );
        }
    }

    private function registerBlog(UrlContainerInterface $sitemap): void
    {
        foreach ($this->iterate(BlogPostCategory::class) as $category) {
            $sitemap->addUrl(
                $this->url(
                    'blog-post-list-by-category',
                    ['categorySlug' => $category->getSlug()]
                ),
                'blog'
            );
        }

        foreach ($this->iterate(BlogPost::class) as $post) {
            foreach ($post->getCategories() as $category) {
                $sitemap->addUrl(
                    $this->url(
                        'blog-post-detail',
                        [
                            'categorySlug' => $category->getSlug(),
                            'postDate' => $post->getDate()->format('Y-m-d'),
                            'postSlug' => $post->getSlug(),
                        ]
                    ),
                    'blog'
                );
            }
        }
    }

    private function url(string $route, array $parameters = []): UrlConcrete
    {
        return new UrlConcrete(
            $this->router->generate($route, $parameters, RouterInterface::ABSOLUTE_URL)
        );
    }

    private function iterate(string $class): \Generator
    {
        /** @var EntityRepository $repository */
        $repository = $this->doctrine->getManager()->getRepository($class);
        foreach ($repository->createQueryBuilder('o')->getQuery()->iterate() as $result) {
            yield $result[0];
        }
    }
}

Détaillons un peu tout ce code.

  • getSubscribedEvents nous vient de EventSubscriberInterface elle sert juste à "binder" la méthode buildSitemap sur l'événement que le bundle va déclencher
  • buildSitemap est appelée lorsque le bundle génère le sitemap. A ce moment on pourra y ajouter des éléments. On a découpé notre méthode en 2, histoire de garder le code un peu clair (ce listener peut devenir bien costaud si votre application grossit, en faire plusieurs peut aussi être une idée). Vous aurez remarqué que l'appel à ces méthodes est conditionné, c'est une affaire d'optimisation, afin d'éviter de charger des données inutiles pour des routes non demandées. La "section" vaudra null lorsqu'il sera question de dumper tout le sitemap, et le nom de la section lors du dump d'une section en particulier.
  • registerOffers, registerBlog sont nos méthodes principales, elles itèrent sur les entités enregistrées, et ajoutent des URLs au sitemap
  • url est une méthode factorisée. Elle permet de fabriquer les objets UrlConcrete, en utilisant le router pour remplir notre sitemap
  • iterate est une méthode factorisée. Elle met à disposition de son appelant un itérateur sur une entité passée en paramètre

note : j'ai fait le choix d'un event subscriber, une histoire de préférences, libre à vous d'écrire un listener à la place (ça ne change pas grand-chose de toute façon).

Si vous êtes sur un Symfony < 3.3 (ou si vous avez choisi de ne pas utiliser le PSR4 service discovery), vous devrez créer un service pour votre listener.

services:
    sitemap.listener:
        class: SitemapListener
        tags:
            - { name: kernel.event_subscriber }

A ce moment-là, si vous demandez l'URL /sitemap.xml vous verrez qu'il y a 2 sections dans votre application /sitemap.default.xml & /sitemap.blog.xml. Si vous avez l’œil, vous aurez remarqué cela vient du fait qu'on ait ajouté les URLs à la section "blog" dans notre listener.

Ajouter plus que des URLs

Un sitemap peut contenir bien plus que les URLs des pages de votre application. Vous pouvez y intégrer d'autres ressources, comme les images ou les vidéos présentes dans les pages.

Imaginez par exemple que vos billets de blogs aient des images, si vous souhaitiez les intégrer à votre plan, vous pourriez faire comme ça :

/* ... */
use Presta\SitemapBundle\Sitemap\Url\GoogleImage;
use Presta\SitemapBundle\Sitemap\Url\GoogleImageUrlDecorator;

class SitemapListener /* ... */
{
    /* ... */
    private function registerBlog(UrlContainerInterface $urlContainer): void
    {
        /* ... */
        foreach ($this->iterate(BlogPost::class) as $post) {
            foreach ($post->getCategories() as $category) {
                $url = $this->url(
                    'blog-post-detail',
                    [
                        'categorySlug' => $category->getSlug(),
                        'postDate' => $post->getDate()->format('Y-m-d'),
                        'postSlug' => $post->getSlug(),
                    ],
                );

                $images = $post->getImages();
                if (count($images) > 0) {
                    $url = new GoogleImageUrlDecorator($url);
                    foreach ($images as $idx => $image) {
                        $url->addImage(
                            new GoogleImage($image, sprintf('%s - %d', $post->getTitle(), $idx + 1))
                        );
                    }
                }

                $urlContainer->addUrl($url, 'blog');
            }
        }
    }
    /* ... */
}

note : il existe plusieurs décorateurs pour ajouter des informations à vos routes. Vous les retrouverez tous dans la documentation

Dumper le sitemap

Nous y voilà, vous avez fini d'implémenter toutes les règles qui permettent à votre sitemap de se générer. Et lorsque vous appelez l'URL en question, celui-ci se rend sans difficulté.

Reste une dernière petite chose que vous pouvez faire. En effet, pour le moment, votre sitemap est construit à la demande. Ce qui signifie que chaque fois que vous demandez quelque chose comme /sitemap.xml ou /sitemap.{section}.xml, vous atterrissez sur un contrôleur Symfony appartenant à notre bundle : (contrôleur & routing).

À ce moment-là, tout se met en place : - les routes statiques sont collectées - les listeners sont appelés

À chaque fois !

Pour une petite application, c'est acceptable. Mais pour un site contenant des milliers de pages, pour la plupart dynamiques, ça veut dire beaucoup de temps perdu à faire les mêmes opérations en boucle...

Pour pallier ce problème de performance, le bundle propose une commande permettant de "dumper" les éléments de votre sitemap. Comprenez que chaque élément (index & sections) va être enregistré sous forme de fichier et déposé dans la partie publique de votre application (web/ ou public/). Ainsi, si le fichier est présent, il sera rendu directement à l'appelant, sans avoir à le recalculer et donc sans avoir à interroger Symfony.

php bin/console presta:sitemaps:dump

C'est typiquement le genre de commande que vous allez vouloir appeler de manière régulière (via un CRON par exemple) pour reconstruire le plan de votre application.

Note : Les URLs ajoutées au sitemap doivent être absolues. Pour que cela puisse se faire depuis une commande, vous devrez indiquer au framework votre contexte

Conclusion

Ce qu'il faut retenir :

  • Une option "sitemap" à ajouter sur les routes statiques
  • Un event listener classique pour les routes dynamiques
  • Une commande pour persister les éléments du plan et gagner en temps d'exécution

J'espère que la promesse est tenue !

note : le site que vous êtes entrain de parcourir dispose d'un sitemap, et il est généré par ce bundle.

Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.