First commit
This commit is contained in:
192
app/Services/MarkdownService.php
Normal file
192
app/Services/MarkdownService.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class MarkdownService
|
||||
{
|
||||
private const ALLOWED_TAGS = [
|
||||
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
|
||||
'strong', 'em', 'a', 'img', 'hr', 'br',
|
||||
];
|
||||
|
||||
private const ALLOWED_ATTRS = [
|
||||
'a' => ['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 = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
|
||||
}
|
||||
|
||||
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('<?xml encoding="UTF-8"><body>' . $html . '</body>', 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user