Static Analysis et Code Completion avec Twig

Par - Mise à jour le

Twig, le moteur de template intégré à Symfony, présente de nombreux avantages et 2 points noirs : l'analyse statique et l’auto-complétion de code.

Addicte à la DX que procure les outils modernes tel que PHPStan, Psalm et PHP8... me voici en quête de ces mêmes outils sur le moteur de thème que j'utilise le plus.

Aucune solution clef-en-main pour twig

La place est libre ! Aucune solution en vue côté Symfony.

PHPStorm permet de bénéficier l'autocomplétation que ce soit sur Twig ou Blade à condition de définir les type en commentaire. Côté Blade (Laravel), l'analyse statique commence (2023) à arriver via un plugin phpstan.

Le constat tombe : pour retrouver au cœur des vues PHPStan ou Psalm réactif et l’auto-complétion du code sans avoir à développer et maintenir un plugin... il semble opportun de basculer sur... PHP.

League/Plates semblent être la référence dans la catégorie moteur de thème PHP bien que le projet n'est plus activement maintenu. C'est vers celui-ci que je me suis initialement tourné.

Cet article vous présente donc un voyage de Twig à Leagues/Plates pour avoir un environnement de développement plus efficace.

23 décembre 2024 : Cet article est partiellement valide. J'utilise maintenant un fork de League/Plates qui donne le smile ! Les fichiers Template peuvent s'écrire dans une class, celle-ci est partiellement réécrites (via rector) pour injecter les outils fournis par Plates tout en permettant l'autowiring, la complétion du code et l'analyse statique. Le fichier réécrit est le même que le fichier, il ne s'agit pas d'une mise en cache. J'ai donc migrer un projet en 2 temps : d'abord vers Plates, ensuite vers ce fork. Cette seconde migration n'est pas documentée tout comme ce fork. Il est par contre 100% rétro-compatible avec la librairie originale ce qui a permit une migration douce. Voici un exemple de template. Une poule riqu'ouest a été suggérée, je doute toutefois qu'elle soit validée un jour. Je m'éloigne petit à petit de la librairie initiale amenant des BC dans le développement du fork.

Installer Plates et le configurer pour fonctionner avec Symfony

composer req league/plates

Créer d'un service pour rendre disponible Plates dans l'application Symfony :

<?php

namespace PiedWeb\SeoStatus\Service;

use League\Plates\Engine;

class PlatesTemplateEngine extends Engine
{
    public function __construct(
        PlatesExtension $plateExtension
    ) {
        parent::__construct(__DIR__.'/../templates', '');
        $this->setFileExtension(null);
        $this->loadExtensions([
            $plateExtension,
            // ...
        ]);
    }
}

Créer PlatesExtension précédemment cité pour charger quelques fonctions supplémentaires qui pourront être utiliser dans les templates :

<?php

namespace PiedWeb\SeoStatus\Service;

use Exception;
use League\Plates\Engine;
use League\Plates\Extension\ExtensionInterface;
use League\Plates\Template\Template;
use ReflectionClass;
use ReflectionMethod;

use function Safe\ob_start;

use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\String\UnicodeString;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Environment as TwigEnvironment;

class PlatesExtension implements ExtensionInterface
{
    public Template $template;

    public function __construct(
        private readonly EntrypointLookupInterface $entrypointLookup,
        private readonly AppVariable $twigAppVariable,
        private readonly UrlGeneratorInterface $urlGenerator,
        private readonly TwigEnvironment $twig,
    ) {
    }

    private PlatesTemplateEngine $engine;

    public function register(Engine $engine): void
    {
        $this->engine = $engine instanceof PlatesTemplateEngine ? $engine : throw new Exception();
        $reflectionClass = new ReflectionClass($this);
        foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
            if (\in_array($method->getName(), ['__construct', 'register'])) {
                continue;
            }
            $this->engine->registerFunction($method->getName(), [$this, $method->getName()]); // @phpstan-ignore-line
        }
    }

    public function app(): AppVariable
    {
        return $this->twigAppVariable;
    }

    public function getRequest(): Request
    {
        return $this->app()->getRequest() ?? throw new Exception();
    }

    /**
     * @return string[]
     */
    public function getWebpackCssFiles(string $entryName): array
    {
        return $this->entrypointLookup->getCssFiles($entryName);
    }

    /**
     * @return string[]
     */
    public function getWebpackJsFiles(string $entryName): array
    {
        return $this->entrypointLookup->getJavaScriptFiles($entryName);
    }

    /**
     * @param array<mixed> $parameters
     */
    public function url(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
    {
        return $this->urlGenerator->generate($name, $parameters, $referenceType);
    }

    public function u(string|int|float $string): UnicodeString
    {
        return new UnicodeString((string) $string);
    }

    public function form(FormInterface $form): string
    {
        $form = $form->createView();

        return $this->twig->render('twigForm/form.html.twig', ['form' => $form]);
    }
}

Créer PlatesTemplates dont l'usage sera détaillé après lors de la migration du code Twig :

<?php 
namespace PiedWeb\SeoStatus\Service;

use League\Plates\Template\Template;
 
class PlatesTemplate extends Template
{
}

Note : Voici un scriptpour gérer sa mise à jour de façon automatique. Il permettra d'ajouter les fonctions chargées depuis les extensions en générant un docblock.

SupprimerUse Symfony\Bundle\FrameworkBundle\Controller\AbstractController;dans les controllers afin de créer un AbstractController au niveau de l'app qui permettra d'utiliser Plates sans modifier le code :

<?php
namespace My\App\Controller;

use My\App\Service\PlatesTemplateEngine;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SfAbstractController;
use Symfony\Component\Form\FormInterface;

abstract class AbstractController extends SfAbstractController
{
      
    /**
     * @param array<mixed> $parameters
     */
    protected function renderView(string $view, array $parameters = []): string
    {
        if (str_ends_with($view, 'php')) {
            return parent::renderView($view, $parameters);
        }

        $this->container->get(PlatesTemplateEngine::class)->renderViewForController($view, $parameters);
    }
}

Migrer le code

Transformer tous les fichiers twig en php

find . -type f -name "*.twig" -exec rename 's/\.twig$/.php/' {} \;

Activer l'autocomplétion sur $this

Générer PlatesTemplates.php pour avoir une class documentée qui permettra l’auto-complétion en prenant en compte les fonctions ajoutées via les extensions comme les fonctions disponibles nativement.

Au début de chaque template, insérer :

<?php

use My\App\Service\PlatesTemplate;

/**
 * @var PlatesTemplate $this
 */
?>

(en une ligne pour tous les fichiers)

Dans ce même docblock, détailler les variables requises. Exemple :

<?php

use My\App\Service\PlatesTemplate;

/**
 * @var PlatesTemplate $this
 * @var string $title
 */
?>

<?= $this->e($title) ?>

Memo des équivalents twig/plates

extends vers layout

{% extends 'base.html.twig' %}
<?php $this->layout('test.html') ?>

Presque (trop simple) la fonction layout n'est pas aussi puissante que la fonction extend:

  1. la système de block est moins maléable (détaillé ci-après)
  2. les macro côté twig, devenant en toute logique des function côté php ne sont pas héritées (plusieurs alternatives détaillées ci-après)

block vers section et start

Un block se définit ainsi :

{% block('example') %}
My block
{% endblock %}
<?= $this->section('example', 'My Block') ?>

On remarque déjà que le contenu par défaut est beaucoup moins personnalisable que via twig et qu'unesection peut difficilement contenir unesection enfant contrairement au block twig.

Sa valeur se complète dans les fichiers utilisant layout avec start et stop

<?php $this->start('example') ?>My Block<?php $this->stop() ?>

À noter : on perd la fonctionnalité parent()

Et c'est là que le bas blesse vis à vis de Twig, un thème qui en étend un autre qui en étend un autre via layoutverra chaque bloc ouvert part startrendu même si l'intégralité du contenu n'est pas conservé. Que ce soit en terme de performance ou de fiabilité du code... la solution n'était pas optimal.

Sans entraîner de BC break chez Plates, j'ai donc modifier la syntaxe pour arriver à :

<?php if ($this->start('example')) { ?>
My Block
<?php } $this->stop() ?>

include vers fetch ou insert

{% include 'example.html.twig' %}
<?= $this->fetch('example.html.php') ?>
<?php $this->insert('example.html.php') ?>

Ciao les macro

3 alternatives semblent possibles :

Générer les formulaires form form_widget

Ici, on arrive sur un os.

J'ai pour l'instant contourner la migration du code en gérant les formulaires symfony directement avec twig.

Voir le code Symfony/Twig à faire évoluervers Symfony/Plates

defaut, u et compagnie

Quelques exemples pour ces cas plutôt simple où PHP remplace de manière native les fonctions ou filtres de Twig :

default

{{ exampleVariableWichMayNotExist|default('default value') }}
<?= $exampleVariableWichMayNotExist ?? 'default value' ?>

u.truncate

{{ maybeAVeryLongString|u.truncate(50, '…') }}
<?php
use Symfony\Component\String\UnicodeString;
?>
<?= (new UnicodeString($maybeAVeryLongString)->truncate(50, '…') ?>

Créer une extension

La documentation de Plates et plutôt complète concernant ce point.

Exemple pour transmettre les données du template en cours dans un fetch par exemple :

<?php

namespace MyAppService;

class PlatesAppExtension implements ExtensionInterface
{
    public Template $template;
    
    public function dataTablesLabel(string text, bool $textClickable = true): string 
    { 
        return $this->engine->render(
            'partial/dataTablesLabel.html.php', // LINK src/templates/partial/dataTablesLabel.html.php
            $this->getFuncArgsNamed(__FUNCTION__, unc_get_args()) + $this->template->data()
        );
    }
}

Bonus : Mettre à jour sa config de PHP-CS-Fixer

$config->setRules([ 
    'echo_tag_syntax' => ['format' => 'short'], 
    'semicolon_after_instruction' => false,
]);

Et éventuellement ses paramètres VSCode(settings.json) pour une meilleure lisibilité :

{
"editor.tokenColorCustomizations": {
        "textMateRules": [
            {
                "scope": [
                    "punctuation.definition.tag.begin.php",
                    "punctuation.section.embedded.begin.php",
                    "punctuation.section.embedded.end.php",
                    "punctuation.definition.tag.end.php"
                ],
                "settings": {
                    "foreground": "#94a3b8"
                }
            }
        ]
    },
}

File Navigation

Après plusieurs essais, pour activer la navigation depuis l'IDE vers un fichier de thème, j'ai opté pour l'utilisation d'un commentaire en m'appuyant sur le plugin VS Code File Link:

/** @see src/templates/index.html.php */
return $this->render('index.html.php', $parameters);

Conclusion

La migration est en cours sur une application de taille moyenne (22 fichiers twig et 2650 lignes de code à migrer).

J'ai pour l'instant passé 3 jours sur la migration. Elle arrive à son terme et j'attends quelques retours du gestionnaire actuel du projet plates pour pousser quelques PR ou switcher à un fork.

Le code présenté ci-dessus est un aperçu rapide de mes premiers essais. L'article sera édité et les exemples complétés avec une version plus complète implémentant des fonctionnalités similaires au Bridge entre Twig et Symfony(router, app, ...).

Je vous invite à vous abonner si vous souhaitez être notifier de la mise à jour de cet article.

En fonction de la fin de cette expérience, un package permettant de simplifier la première étape sera publiée sur packagist.

Vos avis et retours sont également appréciés via Twitter ou mail contactpiedweb.com sur ce projet !!

RSSTwitter

Continuer sa lecture sur le blog