first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

205
src/Post/PostExtension.php Normal file
View File

@@ -0,0 +1,205 @@
<?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;
}
}