206 lines
8.0 KiB
PHP
206 lines
8.0 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Post;
|
||
|
||
use Twig\Extension\AbstractExtension;
|
||
use Twig\TwigFunction;
|
||
|
||
/**
|
||
* Extension Twig pour la présentation des articles.
|
||
*
|
||
* Expose des fonctions utilitaires dans les templates Twig
|
||
* afin d'éviter d'appeler de la logique de présentation directement
|
||
* sur le modèle Post depuis les vues.
|
||
*
|
||
* Fonctions disponibles dans les templates :
|
||
*
|
||
* @example {{ post_excerpt(post) }} — extrait de 400 caractères par défaut
|
||
* @example {{ post_excerpt(post, 600) }} — extrait personnalisé de 600 caractères
|
||
* @example {{ post_url(post) }} — URL publique de l'article (/article/{slug})
|
||
* @example {{ post_thumbnail(post) }} — URL de la première image, ou null si aucune image
|
||
* @example {{ post_initials(post) }} — initiales du titre (ex: "AB" pour "Article de Blog")
|
||
*/
|
||
final class PostExtension extends AbstractExtension
|
||
{
|
||
/**
|
||
* Déclare les fonctions Twig exposées aux templates.
|
||
*
|
||
* @return TwigFunction[] Les fonctions enregistrées dans l'environnement Twig
|
||
*/
|
||
public function getFunctions(): array
|
||
{
|
||
return [
|
||
new TwigFunction(
|
||
'post_excerpt',
|
||
fn (Post $post, int $length = 400) => self::excerpt($post, $length),
|
||
['is_safe' => ['html']]
|
||
),
|
||
new TwigFunction(
|
||
'post_url',
|
||
fn (Post $post) => '/article/'.$post->getStoredSlug()
|
||
),
|
||
new TwigFunction(
|
||
'post_thumbnail',
|
||
fn (Post $post) => self::thumbnail($post)
|
||
),
|
||
new TwigFunction(
|
||
'post_initials',
|
||
fn (Post $post) => self::initials($post)
|
||
),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Génère un extrait HTML formaté du contenu de l'article.
|
||
*
|
||
* Conserve uniquement les balises sûres et porteuses de sens visuel
|
||
* (<ul>, <ol>, <li>, <strong>, <em>, <b>, <i>) afin que le formatage
|
||
* soit perceptible dans l'aperçu (listes à puces, gras, italique…).
|
||
* Toutes les autres balises sont supprimées par strip_tags().
|
||
*
|
||
* La hauteur de l'aperçu est contrainte côté CSS (max-height sur .card__body +
|
||
* dégradé de fondu sur .card__excerpt) — c'est CSS qui tronque visuellement,
|
||
* pas cette méthode. Le paramètre $length sert uniquement de garde-fou serveur :
|
||
* il évite d'envoyer l'intégralité d'un long article au navigateur. La valeur
|
||
* par défaut de 400 caractères est volontairement généreuse pour ne jamais
|
||
* couper un contenu que CSS aurait affiché en entier.
|
||
*
|
||
* La troncature opère sur le HTML filtré (pas sur le texte brut) afin de
|
||
* conserver le formatage de façon cohérente, quelle que soit la longueur
|
||
* du contenu. Le comptage de caractères ignore les balises.
|
||
*
|
||
* Le HTML retourné provient de HTMLPurifier (appliqué à l'écriture) —
|
||
* strip_tags() avec liste blanche élimine tout balisage résiduel non désiré.
|
||
* La fonction est déclarée is_safe => ['html'] : Twig ne l'échappe pas
|
||
* automatiquement, le |raw est inutile dans les templates.
|
||
*
|
||
* @param Post $post L'article dont générer l'extrait
|
||
* @param int $length Longueur maximale en caractères visibles (défaut : 400)
|
||
*
|
||
* @return string L'extrait en HTML partiel, tronqué si nécessaire
|
||
*/
|
||
private static function excerpt(Post $post, int $length): string
|
||
{
|
||
// Balises conservées : structurantes pour les listes, sémantiques pour le gras/italique.
|
||
// Toutes les autres (p, div, h1-h6, img, a, table…) sont supprimées pour
|
||
// garder un aperçu compact.
|
||
$allowed = '<ul><ol><li><strong><em><b><i>';
|
||
$html = strip_tags($post->getContent(), $allowed);
|
||
|
||
// Mesurer sur le texte brut : les balises ne comptent pas dans la limite visible
|
||
if (mb_strlen(strip_tags($html)) <= $length) {
|
||
return $html;
|
||
}
|
||
|
||
// Tronquer en avançant caractère par caractère dans le HTML, en ignorant
|
||
// les balises dans le comptage — le formatage est ainsi conservé dans la
|
||
// portion visible, de façon cohérente avec les articles courts.
|
||
$truncated = '';
|
||
$count = 0;
|
||
$inTag = false;
|
||
|
||
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
|
||
$char = mb_substr($html, $i, 1);
|
||
|
||
if ($char === '<') {
|
||
$inTag = true;
|
||
}
|
||
|
||
$truncated .= $char;
|
||
|
||
if ($inTag) {
|
||
if ($char === '>') {
|
||
$inTag = false;
|
||
}
|
||
} else {
|
||
$count++;
|
||
}
|
||
}
|
||
|
||
// Fermer proprement les balises laissées ouvertes par la troncature
|
||
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
|
||
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
|
||
$closes = substr_count($truncated, "</{$tag}>");
|
||
for ($j = $closes; $j < $opens; $j++) {
|
||
$truncated .= "</{$tag}>";
|
||
}
|
||
}
|
||
|
||
return $truncated . '…';
|
||
}
|
||
|
||
/**
|
||
* Extrait l'URL de la première image présente dans le contenu de l'article.
|
||
*
|
||
* Utilise une regex sur l'attribut src de la première balise <img> trouvée.
|
||
* Le contenu étant sanitisé par HTMLPurifier, seuls les schémas http/https
|
||
* sont présents — aucun risque XSS via cet attribut.
|
||
* L'échappement de l'URL est délégué à Twig (auto-escape activé).
|
||
*
|
||
* @param Post $post L'article dont extraire la vignette
|
||
*
|
||
* @return string|null L'URL de la première image, ou null si aucune image
|
||
*/
|
||
private static function thumbnail(Post $post): ?string
|
||
{
|
||
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
|
||
return $matches[1];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Génère les initiales du titre de l'article (1 à 2 caractères).
|
||
*
|
||
* Extrait la première lettre de chaque mot, conserve les deux premières,
|
||
* et retourne le résultat en majuscules. Les mots vides (articles, prépositions
|
||
* d'une lettre) sont ignorés pour favoriser les mots porteurs de sens.
|
||
*
|
||
* Exemples :
|
||
* "Article de Blog" → "AB"
|
||
* "Été en forêt" → "EF"
|
||
* "PHP" → "P"
|
||
* "" → "?"
|
||
*
|
||
* L'échappement HTML est délégué à Twig (auto-escape activé).
|
||
*
|
||
* @param Post $post L'article dont générer les initiales
|
||
*
|
||
* @return string Les initiales en majuscules (1–2 caractères), ou "?" si le titre est vide
|
||
*/
|
||
private static function initials(Post $post): string
|
||
{
|
||
// Filtrer les mots vides fréquents (articles, prépositions, coordinations)
|
||
// pour favoriser les mots porteurs de sens : "Article de Blog" → ["Article", "Blog"] → "AB"
|
||
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
|
||
$words = array_filter(
|
||
preg_split('/\s+/', trim($post->getTitle())) ?: [],
|
||
static function (string $w) use ($stopWords): bool {
|
||
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_"));
|
||
|
||
return $normalized !== ''
|
||
&& mb_strlen($normalized) > 1
|
||
&& !in_array($normalized, $stopWords, true);
|
||
}
|
||
);
|
||
|
||
if (empty($words)) {
|
||
// Repli sur le premier caractère du titre brut si tous les mots font 1 lettre
|
||
$first = mb_substr(trim($post->getTitle()), 0, 1);
|
||
|
||
return $first !== '' ? mb_strtoupper($first) : '?';
|
||
}
|
||
|
||
$words = array_values($words);
|
||
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
|
||
|
||
if (isset($words[1])) {
|
||
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
|
||
}
|
||
|
||
return $initials;
|
||
}
|
||
}
|