['href', 'title', 'rel', 'target'], 'img' => ['src', 'alt', 'title', 'loading', 'decoding'], ]; public static function compile(string $markdown, Media $media): string { $markdown = trim($markdown); if ($markdown === '') { throw new RuntimeException('Ajoute du contenu avant de publier.'); } $markdown = self::normalizeMarkdown($markdown); $html = Markdown::instance()->convert($markdown); $html = self::sanitizeAndResolve($html, $media); 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 = '

' . str_replace('
', '

', $fallback) . '

'; } return $html; } // Passe DOM unique : sanitise les balises/attributs et résout les références media:. private static function sanitizeAndResolve(string $html, Media $media): string { $dom = new DOMDocument('1.0', 'UTF-8'); libxml_use_internal_errors(true); $dom->loadHTML('' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); $body = $dom->getElementsByTagName('body')->item(0); if (!$body instanceof DOMElement) { return ''; } self::processNode($body, $media); $out = ''; foreach ($body->childNodes as $child) { $out .= $dom->saveHTML($child); } return trim($out); } private static function processNode(DOMNode $parent, Media $media): void { for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) { $child = $parent->childNodes->item($i); if ($child === null) { continue; } if ($child instanceof DOMComment) { $parent->removeChild($child); continue; } if ($child instanceof DOMText) { continue; } if (!$child instanceof DOMElement) { $parent->removeChild($child); continue; } if (!in_array($child->tagName, self::ALLOWED_TAGS, true)) { self::unwrap($child); continue; } self::sanitizeAttributes($child, $media); // img may have been removed by sanitizeAttributes if ($child->parentNode !== null) { self::processNode($child, $media); } } } private static function sanitizeAttributes(DOMElement $element, Media $media): void { $allowed = self::ALLOWED_ATTRS[$element->tagName] ?? []; $toRemove = []; foreach ($element->attributes as $attribute) { if (!in_array($attribute->name, $allowed, true)) { $toRemove[] = $attribute->name; } } foreach ($toRemove as $name) { $element->removeAttribute($name); } if ($element->tagName === 'a') { $href = trim($element->getAttribute('href')); if ($href === '' || !preg_match('~^(https?:|mailto:|tel:|/)~i', $href)) { $element->removeAttribute('href'); } else { $element->setAttribute('rel', 'noopener noreferrer'); if (preg_match('~^https?://~i', $href)) { $element->setAttribute('target', '_blank'); } } } if ($element->tagName === 'img') { $src = trim($element->getAttribute('src')); if ($src === '' || !str_starts_with($src, 'media:')) { $element->parentNode?->removeChild($element); return; } $fileName = substr($src, 6); $item = $media->findByFileName($fileName); if ($item === null) { throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); } $element->setAttribute('src', (string) $item['url']); $element->setAttribute('loading', 'lazy'); $element->setAttribute('decoding', 'async'); } } 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)); } private static function unwrap(DOMElement $element): void { $parent = $element->parentNode; if ($parent === null) { return; } while ($element->firstChild !== null) { $parent->insertBefore($element->firstChild, $element); } $parent->removeChild($element); } }