← Code-Übersicht

MarkdownToHtml.php

Pfad: src/Infrastructure/Content/MarkdownToHtml.php

Ext: php

Größe: 3815 Bytes

Geändert: 2026-07-15T21:45:39+02:00

Frühere Version vom 2026-07-15T21:45:39+02:00 · zur aktuellen Fassung

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Content;

/**
 * Rendert die erlaubte Markdown-Teilmenge (h1-h6, Absatz, ul/ol, strong)
 * zu sicherem HTML. Textinhalt wird vollstaendig escaped -> kein XSS.
 */
final class MarkdownToHtml
{
    /**
     * Wandelt Markdown in HTML um.
     *
     * @param string $markdown Quelltext.
     * @return string Sicheres HTML.
     */
    public function render(string $markdown): string
    {
        $lines = preg_split('/\r\n|\r|\n/', $markdown) ?: [];
        $html = [];
        $para = [];
        $liste = null;
        foreach ($lines as $line) {
            $t = trim($line);
            if ($t === '') {
                $this->flushAbsatz($html, $para);
                $liste = $this->schliesseListe($html, $liste);
                continue;
            }
            if (preg_match('/^(#{1,6})\s+(.*)$/', $t, $m) === 1) {
                $this->flushAbsatz($html, $para);
                $liste = $this->schliesseListe($html, $liste);
                $n = strlen($m[1]);
                $html[] = "<h{$n}>" . $this->inline($m[2]) . "</h{$n}>";
                continue;
            }
            if (preg_match('/^[-*]\s+(.*)$/', $t, $m) === 1) {
                $this->flushAbsatz($html, $para);
                $liste = $this->oeffneListe($html, $liste, 'ul');
                $html[] = '<li>' . $this->inline($m[1]) . '</li>';
                continue;
            }
            if (preg_match('/^\d+\.\s+(.*)$/', $t, $m) === 1) {
                $this->flushAbsatz($html, $para);
                $liste = $this->oeffneListe($html, $liste, 'ol');
                $html[] = '<li>' . $this->inline($m[1]) . '</li>';
                continue;
            }
            $liste = $this->schliesseListe($html, $liste);
            $para[] = $t;
        }
        $this->flushAbsatz($html, $para);
        $this->schliesseListe($html, $liste);

        return implode("\n", $html);
    }

    /**
     * Gibt einen gesammelten Absatz aus und leert den Puffer.
     *
     * @param list<string> $html Ausgabe-Puffer (per Referenz).
     * @param list<string> $para Absatz-Zeilen (per Referenz).
     */
    private function flushAbsatz(array &$html, array &$para): void
    {
        if ($para !== []) {
            $html[] = '<p>' . $this->inline(implode(' ', $para)) . '</p>';
            $para = [];
        }
    }

    /**
     * Oeffnet bei Bedarf eine Liste des gewuenschten Typs.
     *
     * @param list<string> $html    Ausgabe-Puffer (per Referenz).
     * @param string|null  $aktuell Aktuell offener Listentyp.
     * @param string       $wunsch  Gewuenschter Listentyp (ul|ol).
     * @return string Der nun offene Listentyp.
     */
    private function oeffneListe(array &$html, ?string $aktuell, string $wunsch): string
    {
        if ($aktuell !== $wunsch) {
            $this->schliesseListe($html, $aktuell);
            $html[] = "<{$wunsch}>";
        }

        return $wunsch;
    }

    /**
     * Schliesst eine offene Liste, falls vorhanden.
     *
     * @param list<string> $html    Ausgabe-Puffer (per Referenz).
     * @param string|null  $aktuell Aktuell offener Listentyp.
     * @return null Immer null (keine Liste mehr offen).
     */
    private function schliesseListe(array &$html, ?string $aktuell)
    {
        if ($aktuell !== null) {
            $html[] = "</{$aktuell}>";
        }

        return null;
    }

    /**
     * Escaped Text und wandelt **fett** in <strong> um.
     *
     * @param string $text Roher Zeilentext.
     * @return string Sicheres Inline-HTML.
     */
    private function inline(string $text): string
    {
        $escaped = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');

        return preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $escaped) ?? $escaped;
    }
}