Files
slim-blog/src/Post/PostExtension.php
2026-03-16 01:47:07 +01:00

206 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 (12 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;
}
}