Undefined


Construire un site statique avec PHP

On utilise PHP comme un langage serveur nous permettant de retourner des pages HTML. Pour chaque requête notre script PHP construit la réponse et la retourne on se retrouve a reconstruire requête après requête la même page pour chaque utilisateur. Peut-on, dans certains cas, se contenter de construire la réponse, la stocker et la retourner sans la recalculer à nos utilisateurs ?

Pour certains usages il n'est pas nécessaire d'avoir un serveur pour que notre site s'affiche. Par exemple, un site vitrine a très peu d'interaction avec le client : il doit présenter la vitrine de l'entreprise peu importe l'utilisateur et, à part le formulaire de contact, il n'y a aucun besoin de reconstruire la page d'accueil à chaque requête. Pour ça, bien entendu, on peut ajouter un serveur de cache comme Varnish ou alors simplement un CDN mais c'est un coût qui vient s'ajouter à celui de notre serveur. Il y a une autre approche qui semble être tendance et que l'on appelle la JAMstack.

Comprendre la JAMStack

La JAM Stack n'est pas à proprement parler un simple site statique mais désigne une stack en 3 parties : Javascript, API et Markup (HTML). On va construire un site à partir d'un ensemble de pages HTML puis on va vernir intégrer nos interactions avec l'utilisateur, comme notre formulaire de contact, avec des scripts Javascript et des API.

Il n'y a rien d'incroyable, voire même rien de nouveau, pourtant le terme JAMStack semble assez récent comme nous le montre la courbe Google Trends.

"JAMstack" on Google Trends

Le développement de ce terme est venue, en partie, avec les outils qui facilite la construction de nos pages HTML : les générateurs de site statique (SSG). On en compte plus de 350 sur le site jamstack.org (https://jamstack.org/generators/). Un des plus connus étant Jekyll, et si cela ne vous parle pas, c'est ce qui permet de construire une GitHub Page.

Un autre acteur qui a énormément contribué à ce développement est Netlify qui propose de l'hébergement "serverless". On peut en quelques clics connectés son compte Github et déployer un site statique, venir connecter des fonctions serverless à nos formulaires, faire de l'A/B testing ou encore bénéficier d'un environnement de visualisation.

Et pour finir il nous manque un dernier élément : le CMS. Le CMS est l'outil qui nous permet de construire notre contenu de l'organiser et de l'administrer. Pour ça, aussi, il y a des solutions qui se sont développées : Directus, Contentful, et bien d'autres comme présenté sur le site jamstack.org (https://jamstack.org/headless-cms/). Ces CMS sont particuliers : ils sont Headless. C'est-à-dire que le CMS ne s'occupe plus de notre front : il nous fournit une interface d'administration et des API pour venir construire notre front.

Construire un SSG (Static Site Generator)

J'ai plusieurs fois eu l'occasion de tester des SSG avec différents outils que ce soit pour un blog, une landing page ou encore de la documentation. Mais je me suis essayé au développement d'un SSG en utilisant le framework Symfony. L'idée est de s'éloigner le moins possible de l'expérience par défaut de Symfony pour construire mon blog personnel.

Pour ça on va partir du controller de la page d'accueil :

// src/Controller/HomeController.php

#[Route('/index.html', name: 'home', methods: ['GET'])]
class HomeController extends AbstractController
{
    public function __construct(
        private readonly ArticleRepository $articleRepository,
        private readonly ProjectRepository $projectRepository,
    ) {}

    public function __invoke(): Response
    {
        $content = $this->renderView('home.html.twig', [
            'articles' => $this->articleRepository->findAll(),
            'projects' => $this->projectRepository->findAll(),
        ]);

        return new Response($content, 200);
    }
}

On construit la page en récupérant nos articles et nos projets à afficher. Pour le repository, c'est pas du doctrine comme on peut en avoir l'habitude mais une autre implémentation.

Le ContentRepository

// src/Repository/ArticleRepository.php

final class ArticleRepository extends AbstractContentRepository
{
    public function __construct(ArticleParser $parser, ContentAccessorInterface $accessor)
    {
        parent::__construct(ContentType::ARTICLE, $parser, $accessor);
    }

    protected function defaultOrder($a, $b): int
    {
        return $b->getCreatedAt() <=> $a->getCreatedAt();
    }
}

Dans une version plus aboutie, il est tout à fait possible de faire une interface de nos repositories. Ainsi, on peut passer d'un repository Doctrine utilisant une BDD ou alors des fichiers markdown comme on le fait souvent dans des SSG.

abstract class AbstractContentRepository {

    public function findAll(): array
    {
        $contents = [];

        try {
            foreach ($this->accessor->accessAll($this->contentType) as $rawContent) {
                $contents[] = $this->parser->parse($rawContent);
            }
        } catch (ContentException $exception) {
            $this->logger?->critical($exception->getMessage());
        }

        usort($contents, fn($a, $b) => $this->defaultOrder($a, $b));

        return $contents;
    }
}

On fait d'abord appel à notre accessor pour récupérer les contenus du type Article ($this->contentType). Puis on va utiliser le parser pour convertir notre rawContent en entité Article. Enfin, on applique le tri par défaut qui a été défini dans notre ArticleRepository avant de retourner la collection.

Le ContentAccessor

La première brique nécessaire à la récupération de notre contenu est l'accessor comme son nom l'indique il nous permet d'accéder à nos contenus "bruts". Ici, ce sont de simples fichiers que l'on pourra résoudre de la manière suivante :

final class ContentAccessor implements ContentAccessorInterface {

    public function accessAll(ContentType $type): array
    {
        return array_map(
            fn(\SplFileInfo $file) => $this->access($type, $file->getBasename(self::FILE_EXTENSION)),
            $this->getFiles($type)
        );
    }

    public function access(ContentType $type, string $slug): string
    {
        try {
            $file = sprintf('%s/content/%s/%s%s', $this->projectDir, $type->value, $slug, self::FILE_EXTENSION);
            return file_get_contents($file);
        } catch (\Exception) {
            throw new ContentFileNotFoundException($file);
        }
    }

    protected function getFiles(ContentType $type): array
    {
        ($finder = new Finder())->files()->in(sprintf('%s/content/%s/', $this->projectDir, $type->value));
        if (!$finder->hasResults()) {
            return [];
        }

        return iterator_to_array($finder);
    }
}

En utilisant un pattern prédéfini, on va chercher les fichiers qui se trouvent dans notre répertoire /content/articles/ qui sont de type markdown puis on va lire le contenu avec la méthode file_get_contents. Le découpage va aussi nous permettre d'accéder à un contenu directement depuis son slug avec la méthode access.

Maintenant que nous avons les contenus, on peut les convertir en entité grâce à notre ContentParser.

Le ContentParser

On doit convertir le fichier suivant en une entité PHP.

---
title: "Migration vers PHP 8"
createdAt: "2020-12-20"
slug: "migration-php8"
tags:
- PHP
- Symfony
---

**PHP 8** est sorti depuis maintenant quelques jours,  ...

Le fichier est composé de deux parties :

  • l'en-tête de l'article en yaml
  • le contenu de l'article en markdown

Pour traiter ça, on va s'appuyer sur deux dépendances : Parsedown pour parser le Markdown et Symfony Serializer pour normalizer le YAML.

abstract class MarkdownParser implements ParserInterface {
    public function parse(string $content): object
    {
        $this->logger?->debug('Parse markdown data.');

        $data = explode('---', $content);
        if (null === ($data[1] ?? null) || null === ($data[2] ?? null)) {
            throw new ContentParseException("Failed to parse data.");
        }

        /** @var ContentInterface $object */
        $object = new ($this->contentClass)();

        $this->parseHeaders($object, $data[1]);
        $this->parseContent($object, $data[2]);

        return $object;
    }

    protected function parseHeaders($object, string $rawHeaders): void
    {
        $this->serializer->deserialize(
            $rawHeaders,
            $this->contentClass,
            'yaml',
            [AbstractNormalizer::OBJECT_TO_POPULATE => $object]
        );
    }

    protected function parseContent($object, string $rawContent): void
    {
        $object->setContent((new \Parsedown())->parse($rawContent));
    }
}

Et voilà, on a fait le tour de notre premier problème : comment construire un modèle basé sur des fichiers markdown. On aurait pu bien entendu utiliser l'API d'un Headless CMS ou alors uneBDD contenant nos articles mais là on a ce qui se rapproche le plus de la philosophie des SSG.

Mais maintenant que l'on a vu ça il nous faut écrire le résultat de notre controller dans des fichiers HTML.

La commande Static Site Generator

On va encore utiliser un élément connu de Symfony : le composant symfony/console qui nous permet d'écrire des commandes CLI.

#[AsCommand('site:generate', 'Generate static site.')]
final class StaticSiteGeneratorCommand extends Command
{
    public function __construct(private readonly RouteDumper $dumper, string $name = null)
    {
        parent::__construct($name);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $writer = new ContentWriter(dirname(__DIR__, 3) . '/static');
        $this->dumper->dump($writer);
        return Command::SUCCESS;
    }
}

Notre commande va instancier un ContentWriter avec comme paramètre ../../../static. Pas terrible, mais pour un PoC ça a le mérite de remonter à la racine de notre projet et de préciser le répertoire static, dans lequel on cherche a construire notre site statique.

final class ContentWriter
{
    private Filesystem $filesystem;

    public function write(string $path, string $content): void
    {
        $this->filesystem->dumpFile(sprintf('%s%s', $this->outputDir, $path), $content);
    }
}

Le ContentWriter est très simple et va simplement écrire le contenu qu'on lui donne dans le chemin voulu de notre outputDir.

Puis on passe en paramètre du RouteDumper notre ContentWriter.

Tout d'abord, voyons la méthode dump :

    public function dump(ContentWriter $writer): void
    {
        $routes = $this->router->getRouteCollection();

        $this->dumpContentPages($routes, $writer);
        $this->dumpStaticPages($routes, $writer);
    }

On va utiliser le Router de Symfony pour récupérer notre collection de route et on va traiter parmi ces routes les pages dites statiques (sans paramètre) et les pages dites de contenu qui intègre un slug dans l'URL.

Pour les pages statiques d'abord :

private function dumpStaticPages(RouteCollection $routes, ContentWriter $writer): void
{
    foreach ($routes as $route) {
        if (!($route instanceof Route) || !empty($route->getRequirements()) || $route->getPath() === '/') {
            continue;
        }

        $writer->write($route->getPath(), $this->resolve($route));
    }
}

Puis, pour les pages de contenus :

private function dumpContentPages(RouteCollection $routes, ContentWriter $writer): void
{
    foreach (ContentType::cases() as $contentType) {
        $slugs = $this->accessor->getSlugs($contentType);

        $route = $routes->get($contentType->value);

        foreach ($slugs as $slug) {
            $parameters = ['slug' => $slug];
            $writer->write(
                $this->router->generate($contentType->value, $parameters),
                $this->resolve($route, $parameters)
            );
        }
    }
}

On arrive de cette manière à reconstruire toutes les routes que l'on peut résoudre avec notre méthode resolve :

private function resolve(Route $route, array $parameters = []): string
{
    $request = new Request();
    $request->attributes->set('_controller', $route->getDefault('_controller'));

    foreach ($parameters as $parameterName => $parameterValue) {
        $request->attributes->set($parameterName, $parameterValue);
    }

    $response = $this->httpKernel->handle($request);

    return $response->getContent();
}

La méthode s'occupe de reconstruire notre requête avant de la passer au kernel pour enfin récupérer le content (HTML) de notre réponse.

Cette fois-ci, on a bien toutes les briques nous permettant de construire notre site de manière statique.

La version complète du projet est disponible sur mon GitHub : https://github.com/adrien-chinour/static-php


  • POC

  • PHP

  • Symfony