['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
];
public function compile(string $markdown, Media $media): string
{
$markdown = trim($markdown);
if ($markdown === '') {
throw new RuntimeException('Ajoute du contenu avant de publier.');
}
$doc = new DOMDocument('1.0', 'UTF-8');
$html = '
' . Markdown::instance()->convert($this->escapeRawHtml($markdown)) . '
';
$previous = libxml_use_internal_errors(true);
$doc->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $doc->getElementById('content');
if (!$root instanceof DOMElement) {
throw new RuntimeException('Impossible de générer le contenu HTML.');
}
$this->sanitizeNode($root, $media);
$output = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$output .= $doc->saveHTML($child);
}
return trim($output);
}
private function escapeRawHtml(string $markdown): string
{
return preg_replace_callback(
'~|?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
static fn(array $match): string => str_replace(['<', '>'], ['<', '>'], $match[0]),
$markdown
) ?? $markdown;
}
private function sanitizeNode(DOMNode $parent, Media $media): void
{
foreach (iterator_to_array($parent->childNodes) as $child) {
if (!$child instanceof DOMElement) {
continue;
}
$tag = strtolower($child->tagName);
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
$this->unwrap($child);
$this->sanitizeNode($parent, $media);
continue;
}
foreach (iterator_to_array($child->attributes) as $attribute) {
if (!in_array(strtolower($attribute->name), self::ALLOWED_ATTRIBUTES[$tag] ?? [], true)) {
$child->removeAttributeNode($attribute);
}
}
if ($tag === 'a') {
$this->sanitizeLink($child);
}
if ($tag === 'img') {
$this->sanitizeImage($child, $media);
if (!$child->parentNode) {
continue;
}
}
$this->sanitizeNode($child, $media);
}
}
private function sanitizeLink(DOMElement $node): void
{
$href = trim((string) $node->getAttribute('href'));
if (!$this->isAllowedHref($href)) {
$this->unwrap($node);
return;
}
$node->setAttribute('href', $href);
$node->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$node->setAttribute('target', '_blank');
return;
}
$node->removeAttribute('target');
}
private function sanitizeImage(DOMElement $node, Media $media): void
{
$src = trim((string) $node->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$node->parentNode?->removeChild($node);
return;
}
$item = $media->findByFileName(substr($src, 6));
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$node->setAttribute('src', $item['url']);
$node->setAttribute('alt', trim((string) $node->getAttribute('alt')) ?: (string) $item['alt']);
$node->setAttribute('width', (string) $item['width']);
$node->setAttribute('height', (string) $item['height']);
$node->setAttribute('loading', 'lazy');
$node->setAttribute('decoding', 'async');
}
private function unwrap(DOMElement $node): void
{
$parent = $node->parentNode;
if (!$parent) {
return;
}
if (in_array(strtolower($node->tagName), ['script', 'style'], true)) {
$parent->removeChild($node);
return;
}
while ($node->firstChild) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
}
private function isAllowedHref(string $href): bool
{
if ($href === '' || str_starts_with($href, '//')) {
return false;
}
if (str_starts_with($href, '#') || str_starts_with($href, '/')) {
return true;
}
if (preg_match('~^(?:https?://|mailto:)~i', $href)) {
return true;
}
return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href);
}
}