Less home code more F3

This commit is contained in:
julien
2026-03-30 00:00:03 +02:00
parent d71cf304a9
commit fac7f60190
30 changed files with 818 additions and 1552 deletions

View File

@@ -4,13 +4,8 @@ declare(strict_types=1);
class MarkdownService extends Prefab
{
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_ATTRIBUTES = [
private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
private const ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
];
@@ -22,190 +17,123 @@ class MarkdownService extends Prefab
throw new RuntimeException('Ajoute du contenu avant de publier.');
}
$html = Markdown::instance()->convert($markdown);
$document = $this->parseFragment($html);
$this->sanitizeTree($document, $media);
return trim($this->renderFragment($document));
}
private function parseFragment(string $html): DOMDocument
{
$document = new DOMDocument('1.0', 'UTF-8');
$wrapper = '<div id="markdown-root">' . $html . '</div>';
$markdown = $this->neutralizeRawHtml($markdown);
$doc = new DOMDocument('1.0', 'UTF-8');
$html = '<div id="content">' . Markdown::instance()->convert($markdown) . '</div>';
$previous = libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="utf-8" ?>' . $wrapper,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $document;
}
private function renderFragment(DOMDocument $document): string
{
$root = $document->getElementById('markdown-root');
if ($root === null) {
$root = $doc->getElementById('content');
if (!$root) {
return '';
}
$html = '';
$this->sanitizeChildren($root, $media);
$out = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$html .= $document->saveHTML($child);
$out .= $doc->saveHTML($child);
}
return $html;
return trim($out);
}
private function sanitizeTree(DOMDocument $document, Media $media): void
private function neutralizeRawHtml(string $markdown): string
{
$root = $document->getElementById('markdown-root');
if ($root === null) {
return;
}
$this->sanitizeChildren($root, $media);
return preg_replace_callback(
'~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
static fn(array $match): string => str_replace(['<', '>'], ['&lt;', '&gt;'], $match[0]),
$markdown
) ?? $markdown;
}
private function sanitizeChildren(DOMNode $parent, Media $media): void
{
foreach (iterator_to_array($parent->childNodes) as $child) {
if ($child instanceof DOMElement) {
$tag = strtolower($child->tagName);
if (!$child instanceof DOMElement) {
continue;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
$this->dropDisallowedElement($child);
$tag = strtolower($child->tagName);
if (!in_array($tag, self::TAGS, true)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue;
}
foreach (iterator_to_array($child->attributes) as $attr) {
if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) {
$child->removeAttributeNode($attr);
}
}
if ($tag === 'a') {
$href = trim((string) $child->getAttribute('href'));
if (!$this->allowedHref($href)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue;
}
$this->sanitizeElement($child, $media);
$this->sanitizeChildren($child, $media);
$child->setAttribute('href', $href);
$child->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$child->setAttribute('target', '_blank');
} else {
$child->removeAttribute('target');
}
}
}
}
private function dropDisallowedElement(DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
if ($tag === 'img') {
$src = trim((string) $child->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$child->parentNode?->removeChild($child);
continue;
}
if (in_array(strtolower($element->tagName), ['script', 'style'], true)) {
$parent->removeChild($element);
return;
}
$item = $media->findByFileName(substr($src, 6));
if (!$item) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
private function sanitizeElement(DOMElement $element, Media $media): void
{
$tag = strtolower($element->tagName);
$allowedAttributes = self::ALLOWED_ATTRIBUTES[$tag] ?? [];
foreach (iterator_to_array($element->attributes) as $attribute) {
if (!in_array(strtolower($attribute->name), $allowedAttributes, true)) {
$element->removeAttributeNode($attribute);
$child->setAttribute('src', $item['url']);
$child->setAttribute('alt', trim((string) $child->getAttribute('alt')) ?: (string) $item['alt']);
$child->setAttribute('width', (string) $item['width']);
$child->setAttribute('height', (string) $item['height']);
$child->setAttribute('loading', 'lazy');
$child->setAttribute('decoding', 'async');
}
}
if ($tag === 'a') {
$this->sanitizeLink($element);
return;
}
if ($tag === 'img') {
$this->sanitizeImage($element, $media);
$this->sanitizeChildren($child, $media);
}
}
private function sanitizeLink(DOMElement $element): void
private function unwrap(DOMElement $node): void
{
$href = trim((string) $element->getAttribute('href'));
if (!$this->isAllowedHref($href)) {
$this->unwrapElement($element);
$parent = $node->parentNode;
if (!$parent) {
return;
}
$element->setAttribute('href', $href);
$element->setAttribute('rel', 'noopener noreferrer');
if ($this->isExternalHttpUrl($href)) {
$element->setAttribute('target', '_blank');
} else {
$element->removeAttribute('target');
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 sanitizeImage(DOMElement $element, Media $media): void
private function allowedHref(string $href): bool
{
$src = trim((string) $element->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$element->parentNode?->removeChild($element);
return;
}
$fileName = substr($src, 6);
if ($fileName === '') {
$element->parentNode?->removeChild($element);
return;
}
$item = $media->findByFileName($fileName);
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$alt = trim((string) $element->getAttribute('alt'));
if ($alt === '') {
$alt = (string) ($item['alt'] ?? '');
}
$element->setAttribute('src', (string) $item['url']);
$element->setAttribute('alt', $alt);
$element->setAttribute('loading', 'lazy');
$element->setAttribute('decoding', 'async');
$width = (int) ($item['width'] ?? 0);
if ($width > 0) {
$element->setAttribute('width', (string) $width);
} else {
$element->removeAttribute('width');
}
$height = (int) ($item['height'] ?? 0);
if ($height > 0) {
$element->setAttribute('height', (string) $height);
} else {
$element->removeAttribute('height');
}
}
private function unwrapElement(DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
private function isAllowedHref(string $href): bool
{
if ($href == '') {
if ($href === '') {
return false;
}
@@ -219,9 +147,4 @@ class MarkdownService extends Prefab
return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href);
}
private function isExternalHttpUrl(string $href): bool
{
return (bool) preg_match('~^https?://~i', $href);
}
}