Less home code more F3

This commit is contained in:
julien
2026-03-28 17:37:22 +01:00
parent d2e70af739
commit 16850386d3
19 changed files with 232 additions and 429 deletions

View File

@@ -29,7 +29,7 @@ class AuthController extends BaseController
return;
}
session_regenerate_id(true);
session_regenerate_id(true); // Prévient la fixation de session.
$this->f3->set('SESSION.user_id', $user['id']);
$this->flash('success', 'Connexion réussie.');
$this->f3->reroute('@dashboard');
@@ -39,7 +39,7 @@ class AuthController extends BaseController
{
$this->verifyCsrf();
$this->f3->clear('SESSION.user_id');
session_regenerate_id(true);
session_regenerate_id(true); // Invalide l'ancien ID de session.
$this->flash('success', 'Déconnexion effectuée.');
$this->f3->reroute('@login');
}

View File

@@ -7,8 +7,8 @@ abstract class BaseController
protected Base $f3;
protected DB\SQL $db;
private ?array $resolvedUser = null;
private bool $userResolved = false;
// false = pas encore résolu, null = résolu sans utilisateur.
private array|false|null $resolvedUser = false;
public function __construct()
{
@@ -20,9 +20,9 @@ abstract class BaseController
{
$user = $this->currentUser();
// Les pages publiques peuvent rester cacheables avec le TTL demandé.
// Les pages publiques restent cacheables avec le TTL demandé.
// Si un utilisateur est connecté, le layout dépend de la session
// (navigation d'admin, déconnexion + CSRF) : on force expire(0)
// (navigation admin, déconnexion + CSRF) : on force expire(0)
// pour ne pas servir ce rendu à d'autres visiteurs.
$this->f3->expire($user !== null ? 0 : $cacheTtl);
@@ -37,19 +37,17 @@ abstract class BaseController
'metaDescription' => null,
]);
// Mémoriser en session la valeur exposée à @CSRF pour que les
// formulaires rendus pendant cette réponse puissent être vérifiés
// lors du POST suivant par verifyCsrf().
// Recopier @CSRF en session pour que verifyCsrf() puisse
// vérifier le jeton soumis au POST suivant.
$this->f3->copy('CSRF', 'SESSION.csrf');
echo Template::instance()->render('layout.html');
}
protected function currentUser(): ?array
{
if (!$this->userResolved) {
if ($this->resolvedUser === false) {
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
$this->resolvedUser = $userId > 0 ? (new User($this->db))->findById($userId) : null;
$this->userResolved = true;
}
return $this->resolvedUser;
@@ -65,15 +63,12 @@ abstract class BaseController
$this->f3->reroute('@login');
}
// La classe Session de F3 expose la valeur courante via « CSRF ».
// Au rendu, on la recopie en SESSION.csrf ; le formulaire renvoie
// ensuite le jeton affiché lors du rendu précédent, qu'on compare à
// la valeur mémorisée en session.
protected function verifyCsrf(): void
{
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
$expected = (string) ($this->f3->get('SESSION.csrf') ?? '');
// hash_equals : comparaison en temps constant contre les attaques temporelles.
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
return;
}

View File

@@ -12,13 +12,15 @@ class DashboardController extends BaseController
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Post($this->db))->paginateList($page, 24);
$media = new Media($this->db);
$result = (new Post($this->db))->paginateList($page, 24, $media);
$this->render('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['posts'],
'pagination' => $result,
'paginationAlias' => 'dashboard',
'adminMode' => true,
]);
}
}

View File

@@ -81,7 +81,19 @@ class MediaController extends BaseController
$this->verifyCsrf();
try {
(new Media($this->db))->delete((int) $this->f3->get('PARAMS.id'));
$id = (int) $this->f3->get('PARAMS.id');
$media = new Media($this->db);
$item = $media->findById($id);
if ($item === null) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post($this->db))->isMediaUsed($item['id'], $item['file_name'])) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$media->delete($id);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());

View File

@@ -20,14 +20,15 @@ class PostController extends BaseController
{
$this->verifyCsrf();
$media = new Media($this->db);
$input = $this->postInput();
try {
(new Post($this->db))->create($input);
(new Post($this->db))->create($input, $media);
$this->flash('success', 'Article créé.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage(), $media);
}
}
@@ -46,11 +47,12 @@ class PostController extends BaseController
{
$this->verifyCsrf();
$media = new Media($this->db);
$id = (int) $this->f3->get('PARAMS.id');
$input = $this->postInput() + ['id' => $id];
try {
$updated = (new Post($this->db))->updatePost($id, $input);
$updated = (new Post($this->db))->updatePost($id, $input, $media);
if (!$updated) {
$this->f3->error(404, 'Article introuvable.');
return;
@@ -59,7 +61,7 @@ class PostController extends BaseController
$this->flash('success', 'Article mis à jour.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage(), $media);
}
}
@@ -77,14 +79,15 @@ class PostController extends BaseController
$this->f3->reroute('@dashboard');
}
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null, ?Media $media = null): void
{
$media ??= new Media($this->db);
$coverPreview = null;
if (!empty($post['cover_media_id'])) {
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
$coverPreview = $media->findById((int) $post['cover_media_id']);
}
$media = new Media($this->db);
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
$mediaCount = $media->count();
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;

View File

@@ -7,7 +7,8 @@ class SiteController extends BaseController
public function home(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Post($this->db))->paginateList($page);
$media = new Media($this->db);
$result = (new Post($this->db))->paginateList($page, 12, $media);
$this->render('site/home.html', [
'pageTitle' => 'Accueil',
@@ -19,7 +20,8 @@ class SiteController extends BaseController
public function show(): void
{
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'));
$media = new Media($this->db);
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'), $media);
if ($post === null) {
$this->f3->error(404, 'Article introuvable.');
return;

View File

@@ -50,6 +50,25 @@ class Media extends DB\SQL\Mapper
);
}
public function findByIds(array $ids): array
{
$ids = array_filter(array_unique(array_map('intval', $ids)));
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$results = $this->find(["id IN ($placeholders)", ...array_values($ids)]);
$map = [];
foreach ($results ?: [] as $m) {
$row = $this->decorate($m->cast());
$map[$row['id']] = $row;
}
return $map;
}
public function findById(int $id): ?array
{
if ($id <= 0) {
@@ -66,18 +85,19 @@ class Media extends DB\SQL\Mapper
return $this->dry() ? null : $this->decorate($this->cast());
}
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
// pour dériver un texte alternatif lisible.
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
public function upload(string $srcPath, string $originalName = ''): int
{
$target = null;
$image = null;
$committed = false; // Contrôle le nettoyage de $target dans finally.
try {
$meta = self::inspectUpload($srcPath);
$image = self::openImageResource($srcPath, $meta['mime']);
[$format, $extension] = self::targetFormat($meta['mime']);
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = app_public_media_dir() . '/' . $fileName;
@@ -93,19 +113,15 @@ class Media extends DB\SQL\Mapper
$this->created_at = app_now();
$this->save();
$this->db->commit();
$committed = true;
} catch (Throwable $e) {
$this->db->rollback();
if ($target !== null && is_file($target)) {
@unlink($target);
}
throw $e;
}
return (int) $this->get('id');
} catch (RuntimeException $e) {
throw $e;
} catch (Throwable) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
} catch (Throwable $e) {
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
} finally {
if ($image instanceof GdImage) {
imagedestroy($image);
@@ -113,6 +129,9 @@ class Media extends DB\SQL\Mapper
if (is_file($srcPath)) {
@unlink($srcPath);
}
if (!$committed && $target !== null && is_file($target)) {
@unlink($target);
}
}
}
@@ -129,16 +148,12 @@ class Media extends DB\SQL\Mapper
public function delete(int $id): void
{
$item = $this->findById($id);
if ($item === null) {
$this->load(['id = ?', $id]);
if ($this->dry()) {
throw new RuntimeException('Image introuvable.');
}
if ($this->isUsed($item)) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$path = app_public_media_dir() . '/' . $item['file_name'];
$path = app_public_media_dir() . '/' . $this->file_name;
$this->db->begin();
try {
@@ -203,6 +218,8 @@ class Media extends DB\SQL\Mapper
return $image;
}
// PNG/WebP → PNG pour préserver la transparence de manière fiable.
// JPG reste en JPG (pas de canal alpha).
private static function targetFormat(string $mime): array
{
return match ($mime) {
@@ -239,15 +256,6 @@ class Media extends DB\SQL\Mapper
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
}
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
private function isUsed(array $item): bool
{
return $this->db->exec(
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
[$item['id'], '%media:' . $item['file_name'] . '%']
) !== [];
}
private function decorate(array $row): array
{
$alt = (string) $row['alt'];

View File

@@ -39,7 +39,7 @@ class Post extends DB\SQL\Mapper
];
}
public function paginateList(int $page = 1, int $perPage = 12): array
public function paginateList(int $page, int $perPage, Media $media): array
{
$result = $this->paginate(
max(0, $page - 1),
@@ -49,11 +49,13 @@ class Post extends DB\SQL\Mapper
);
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
$covers = $this->loadCovers($posts);
$coverIds = array_filter(array_unique(array_column($posts, 'cover_media_id')));
$covers = $media->findByIds($coverIds);
foreach ($posts as &$post) {
$cover = $covers[$post['cover_media_id']] ?? null;
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
$post['cover_url'] = $cover['url'] ?? '';
$post['cover_alt'] = $cover['alt'] ?? '';
}
return [
@@ -63,7 +65,7 @@ class Post extends DB\SQL\Mapper
];
}
public function findBySlug(string $slug): ?array
public function findBySlug(string $slug, Media $media): ?array
{
$this->load(['slug = ?', $slug]);
if ($this->dry()) {
@@ -71,9 +73,9 @@ class Post extends DB\SQL\Mapper
}
$post = $this->summaryRow($this->cast());
$covers = $this->loadCovers([$post]);
$cover = $covers[$post['cover_media_id']] ?? null;
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
$cover = $post['cover_media_id'] > 0 ? $media->findById($post['cover_media_id']) : null;
$post['cover_url'] = $cover['url'] ?? '';
$post['cover_alt'] = $cover['alt'] ?? '';
$post['body_html'] = (string) $this->body_html;
return $post;
@@ -99,10 +101,10 @@ class Post extends DB\SQL\Mapper
];
}
public function create(array $input): int
public function create(array $input, Media $media): int
{
$payload = $this->payload($input);
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
$payload = $this->payload($input, $media);
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
$now = app_now();
$this->reset();
@@ -116,20 +118,31 @@ class Post extends DB\SQL\Mapper
return (int) $this->get('id');
}
public function updatePost(int $id, array $input): bool
public function updatePost(int $id, array $input, Media $media): bool
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
return false;
}
$payload = $this->payload($input);
$payload = $this->payload($input, $media);
$this->copyfrom($payload + ['updated_at' => app_now()]);
$this->save();
return true;
}
// Vérifie les deux usages possibles : couverture (cover_media_id)
// et images insérées dans le corps (media:filename dans body_markdown).
public function isMediaUsed(int $mediaId, string $fileName): bool
{
return $this->count([
'cover_media_id = ? OR body_markdown LIKE ?',
$mediaId,
'%media:' . $fileName . '%',
]) > 0;
}
public function delete(int $id): void
{
$this->load(['id = ?', $id]);
@@ -140,7 +153,7 @@ class Post extends DB\SQL\Mapper
$this->erase();
}
private function payload(array $input): array
private function payload(array $input, Media $media): array
{
$title = trim((string) ($input['title'] ?? ''));
$excerpt = trim((string) ($input['excerpt'] ?? ''));
@@ -160,8 +173,6 @@ class Post extends DB\SQL\Mapper
throw new RuntimeException("L'extrait est trop long.");
}
$media = new Media($this->db);
$coverId = null;
if ($coverMediaId !== '') {
$coverId = (int) $coverMediaId;
@@ -181,11 +192,6 @@ class Post extends DB\SQL\Mapper
];
}
private function slugExists(string $slug): bool
{
return $this->count(['slug = ?', $slug]) > 0;
}
private function summaryRow(array $row): array
{
return [
@@ -199,24 +205,4 @@ class Post extends DB\SQL\Mapper
];
}
private function loadCovers(array $posts): array
{
$ids = array_filter(array_unique(array_column($posts, 'cover_media_id')));
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = $this->db->exec(
"SELECT id, file_name FROM media WHERE id IN ($placeholders)",
array_values($ids)
);
$map = [];
foreach ($rows as $row) {
$map[(int) $row['id']] = $row;
}
return $map;
}
}

View File

@@ -31,7 +31,7 @@ class User extends DB\SQL\Mapper
}
$data = $this->cast();
unset($data['password_hash']);
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification.
return $data;
}

View File

@@ -17,238 +17,68 @@ class MarkdownService extends Prefab
throw new RuntimeException('Ajoute du contenu avant de publier.');
}
$markdown = self::normalizeMarkdown($markdown);
$html = Markdown::instance()->convert($markdown);
$html = self::sanitizeAndResolve($html, $media);
$html = strip_tags($html, self::ALLOWED_TAGS);
$html = self::resolveImages($html, $media);
$html = self::secureLinks($html);
if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) {
$fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$html = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
}
return $html;
return trim($html);
}
// Reconstruction en liste blanche : les descendants d'une balise interdite
// sont retraités récursivement avant d'être réinsérés.
private static function sanitizeAndResolve(string $html, Media $media): string
// Résout les images media:filename et supprime les images externes.
private static function resolveImages(string $html, Media $media): string
{
$source = new DOMDocument('1.0', 'UTF-8');
$clean = new DOMDocument('1.0', 'UTF-8');
$cleanBody = $clean->createElement('body');
$clean->appendChild($cleanBody);
$previousUseInternalErrors = libxml_use_internal_errors(true);
$source->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previousUseInternalErrors);
$sourceBody = $source->getElementsByTagName('body')->item(0);
if (!$sourceBody instanceof DOMElement) {
return '';
}
self::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media);
$out = '';
for ($i = 0; $i < $cleanBody->childNodes->length; $i++) {
$child = $cleanBody->childNodes->item($i);
if ($child !== null) {
$out .= $clean->saveHTML($child);
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($media): string {
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
return '';
}
}
return trim($out);
$fileName = substr($s[1], 6);
if ($fileName === '') {
return '';
}
$item = $media->findByFileName($fileName);
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
// L'alt du Markdown est déjà échappé par le parser F3.
// Le fallback vers l'alt de la base nécessite un échappement.
$alt = '';
if (preg_match('/alt="([^"]*)"/', $m[0], $a)) {
$alt = $a[1];
}
if ($alt === '') {
$alt = htmlspecialchars($item['alt'], ENT_QUOTES, 'UTF-8');
}
$url = htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8');
return '<img src="' . $url . '" alt="' . $alt . '" loading="lazy" decoding="async">';
}, $html) ?? $html;
}
private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void
// Sécurise les liens : rel="noopener noreferrer" sur tous,
// target="_blank" sur les liens externes uniquement.
private static function secureLinks(string $html): string
{
$children = [];
for ($i = 0; $i < $sourceParent->childNodes->length; $i++) {
$child = $sourceParent->childNodes->item($i);
if ($child !== null) {
$children[] = $child;
}
}
foreach ($children as $child) {
if ($child instanceof DOMComment) {
continue;
return preg_replace_callback('/<a\s[^>]*>/i', function (array $m): string {
if (!preg_match('/href="([^"]*)"/', $m[0], $h)) {
return $m[0];
}
if ($child instanceof DOMText) {
$targetParent->appendChild($target->createTextNode($child->nodeValue ?? ''));
continue;
$attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"';
if (preg_match('~^https?://~i', $h[1])) {
$attrs .= ' target="_blank"';
}
if (!$child instanceof DOMElement) {
continue;
if (preg_match('/title="([^"]*)"/', $m[0], $t)) {
$attrs .= ' title="' . $t[1] . '"';
}
self::appendSanitizedElement($child, $targetParent, $target, $media);
}
}
private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, Media $media): void
{
$tag = strtolower($sourceElement->tagName);
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
self::appendSanitizedChildren($sourceElement, $targetParent, $target, $media);
return;
}
if ($tag === 'img') {
$image = self::buildSanitizedImage($sourceElement, $target, $media);
if ($image !== null) {
$targetParent->appendChild($image);
}
return;
}
$cleanElement = $target->createElement($tag);
self::sanitizeAttributes($sourceElement, $cleanElement);
$targetParent->appendChild($cleanElement);
self::appendSanitizedChildren($sourceElement, $cleanElement, $target, $media);
}
private static function sanitizeAttributes(DOMElement $sourceElement, DOMElement $targetElement): void
{
if ($targetElement->tagName !== 'a') {
return;
}
$href = self::sanitizeHref((string) $sourceElement->getAttribute('href'));
if ($href !== null) {
$targetElement->setAttribute('href', $href);
$targetElement->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href) === 1) {
$targetElement->setAttribute('target', '_blank');
}
}
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
if ($title !== null) {
$targetElement->setAttribute('title', $title);
}
}
private static function buildSanitizedImage(DOMElement $sourceElement, DOMDocument $target, Media $media): ?DOMElement
{
$src = trim((string) $sourceElement->getAttribute('src'));
if ($src === '' || !str_starts_with($src, 'media:')) {
return null;
}
$fileName = substr($src, 6);
if ($fileName === '' || preg_match('/[\x00-\x1F\x7F]/u', $fileName) === 1) {
return null;
}
$item = $media->findByFileName($fileName);
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$image = $target->createElement('img');
$image->setAttribute('src', (string) $item['url']);
$image->setAttribute('loading', 'lazy');
$image->setAttribute('decoding', 'async');
if ($sourceElement->hasAttribute('alt')) {
$image->setAttribute('alt', self::sanitizeAttributeValue((string) $sourceElement->getAttribute('alt'), true) ?? '');
} elseif ((string) $item['alt'] !== '') {
$image->setAttribute('alt', (string) $item['alt']);
} else {
$image->setAttribute('alt', '');
}
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
if ($title !== null) {
$image->setAttribute('title', $title);
}
return $image;
}
private static function sanitizeHref(string $href): ?string
{
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($href === '' || preg_match('/[\x00-\x1F\x7F]/u', $href) === 1) {
return null;
}
if (preg_match('~^(https?://|mailto:|tel:)~i', $href) === 1) {
return $href;
}
if (self::isSafeRelativeHref($href)) {
return $href;
}
return null;
}
private static function isSafeRelativeHref(string $href): bool
{
if ($href === '/') {
return true;
}
if (str_starts_with($href, '//')) {
return false;
}
return preg_match('~^(?:/[^/]|\./|\.\./|#|\?)~', $href) === 1;
}
private static function sanitizeAttributeValue(string $value, bool $allowEmpty = false): ?string
{
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$value = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
if ($value === '' && !$allowEmpty) {
return null;
}
return $value;
}
private static function normalizeMarkdown(string $markdown): string
{
$markdown = str_replace(["\r\n", "\r"], "\n", $markdown);
$lines = explode("\n", $markdown);
$normalized = [];
$inFence = false;
foreach ($lines as $line) {
if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
$inFence = !$inFence;
$normalized[] = $line;
continue;
}
if ($inFence) {
$normalized[] = $line;
continue;
}
$isBlank = trim($line) === '';
$isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1;
$previous = $normalized[count($normalized) - 1] ?? null;
$previousIsBlank = $previous === null || trim($previous) === '';
$previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1;
if ($isListItem && !$previousIsBlank && !$previousIsListItem) {
$normalized[] = '';
}
if (!$isBlank && !$isListItem && $previousIsListItem) {
$normalized[] = '';
}
$normalized[] = $line;
}
return trim(implode("\n", $normalized));
return '<a ' . $attrs . '>';
}, $html) ?? $html;
}
}

View File

@@ -12,7 +12,7 @@
<true>
<div class="card-grid">
<repeat group="{{ @posts }}" value="{{ @post }}">
<include href="partials/post_card_admin.html" />
<include href="partials/post_card.html" />
</repeat>
</div>
<include href="partials/pagination.html" />

View File

@@ -1,14 +1,22 @@
<article class="card article-card">
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
<check if="{{ @post.cover_url }}">
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
<false>
<div class="media-frame media-frame--placeholder">Aucune image</div>
</false>
</check>
</a>
<check if="{{ @post.cover_url }}">
<true>
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
<img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.cover_alt ?: @post.title }}">
</a>
</true>
<false>
<check if="{{ @adminMode }}">
<true>
<div class="media-frame media-frame--placeholder">Aucune image</div>
</true>
</check>
</false>
</check>
<div class="card-body article-card__body">
<h2 class="card-title">{{ @post.title }}</h2>
<h2 class="card-title">
<a class="card-title__link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">{{ @post.title }}</a>
</h2>
<p class="meta-text">
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
<check if="{{ @post.updated_at !== @post.created_at }}">
@@ -16,5 +24,16 @@
</check>
</p>
<p class="card-summary">{{ @post.excerpt }}</p>
<check if="{{ @adminMode }}">
<true>
<div class="card-actions">
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
<button class="button button--danger" type="submit">Supprimer</button>
</form>
</div>
</true>
</check>
</div>
</article>

View File

@@ -1,28 +0,0 @@
<article class="card article-card">
<a class="card-media-link" href="{{ 'post_edit', 'id='.@post.id | alias }}">
<check if="{{ @post.cover_url }}">
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
<false>
<div class="media-frame media-frame--placeholder">Aucune image</div>
</false>
</check>
</a>
<div class="card-body article-card__body">
<h2 class="card-title">{{ @post.title }}</h2>
<p class="meta-text">
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
<check if="{{ @post.updated_at !== @post.created_at }}">
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at | date_fr }}</time></true>
</check>
</p>
<p class="card-summary">{{ @post.excerpt }}</p>
<div class="card-actions">
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Voir</a>
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
<button class="button button--danger" type="submit">Supprimer</button>
</form>
</div>
</div>
</article>

View File

@@ -12,12 +12,8 @@
<check if="{{ @post.cover_url }}">
<true>
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
alt="{{ @post.title }}">
alt="{{ @post.cover_alt ?: @post.title }}">
</true>
<false>
<div class="media-frame media-frame--large media-frame--placeholder article-cover">Aucune image
</div>
</false>
</check>
<div class="prose">{{ @post.body_html | raw }}</div>