convert($markdown);
$html = strip_tags($html, self::ALLOWED_TAGS);
$html = self::resolveImages($html, $media);
$html = self::secureLinks($html);
return trim($html);
}
// Résout les images media:filename et supprime les images externes.
private static function resolveImages(string $html, Media $media): string
{
$f3 = Base::instance();
return preg_replace_callback('/
]*>/i', function (array $m) use ($f3, $media): string {
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
return '';
}
$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 = $f3->encode($item['alt']);
}
$url = $f3->encode($item['url']);
$attrs = 'src="' . $url . '" alt="' . $alt . '"';
// width/height préviennent le layout shift au chargement.
if ((int) $item['width'] > 0) {
$attrs .= ' width="' . (int) $item['width'] . '"';
}
if ((int) $item['height'] > 0) {
$attrs .= ' height="' . (int) $item['height'] . '"';
}
return '
';
}, $html) ?? $html;
}
// Sécurise les liens : rel="noopener noreferrer" sur tous,
// target="_blank" sur les liens externes uniquement.
private static function secureLinks(string $html): string
{
return preg_replace_callback('/]*>/i', function (array $m): string {
if (!preg_match('/href="([^"]*)"/', $m[0], $h)) {
return $m[0];
}
$attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"';
if (preg_match('~^https?://~i', $h[1])) {
$attrs .= ' target="_blank"';
}
if (preg_match('/title="([^"]*)"/', $m[0], $t)) {
$attrs .= ' title="' . $t[1] . '"';
}
return '';
}, $html) ?? $html;
}
}