b2in/app/Services/CmsFluxEditorHtmlTransformer.php
Kevin Adametz 451e738c8b CMS: Absatz-Tags aus dem Rich-Text-Editor erscheinen nicht mehr als Text
Der Editor packt eingefügten Text in <p>; Felder wie die FAQ-Antwort gibt die Website als Text
aus, die Tags waren sichtbar. Beim Speichern entfernt unwrapParagraphs() die Absätze (ein
Absatz ohne <p>, mehrere mit <br><br>, Blockinhalte unverändert). cms_inline() gibt die
Felder als bereinigtes Inline-HTML aus – auch schon gespeicherte Texte mit <p> erscheinen
sauber, Fett und Hervorhebung werden dargestellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:58:48 +00:00

221 lines
6.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Services;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
/**
* Der Flux-Editor (TipTap) nutzt für Hervorhebungen das Element `mark` (Highlight-Extension).
* Im Frontend wird derselbe optische Zweck oft mit `span.text-secondary` umgesetzt.
* Diese Klasse wandelt beim Laden in den Editor und beim Speichern zwischen beiden Formen um.
*/
final class CmsFluxEditorHtmlTransformer
{
/**
* JSON-Felder, die im CMS-Modal mit flux:editor (Rich-Text) bearbeitet werden.
*
* @var list<string>
*/
public const RICH_TEXT_JSON_FIELDS = [
'description',
'text',
'content',
'help',
'answer',
'quote',
];
public static function toEditor(string $html): string
{
if ($html === '' || ! str_contains($html, 'text-secondary')) {
return $html;
}
return self::spansWithClassToMarks($html);
}
public static function fromEditor(string $html): string
{
if ($html === '' || ! str_contains($html, '<mark')) {
return $html;
}
return self::marksToSecondarySpans($html);
}
/**
* Der Editor packt jeden Text in Absätze – auch eingefügten Klartext (12.10.2026). Für Felder,
* die die Website innerhalb einer Zeile oder eines eigenen Absatzes ausgibt, wird daraus
* Inline-HTML: ein Absatz ohne `<p>`, mehrere durch `<br><br>` getrennt, reiner Text als
* Klartext. Enthält der Text Blockelemente (Überschriften, Listen …), bleibt er unverändert.
*/
public static function unwrapParagraphs(string $html): string
{
$html = trim((string) preg_replace('#<p[^>]*>(\s|&nbsp;|<br\s*/?>)*</p>#i', '', $html));
if ($html === '' || ! str_contains($html, '<p')) {
return $html;
}
if (preg_match('#<(?!/?(?:p|strong|b|em|i|mark|span|br|a)\b)[a-z]#i', $html) === 1) {
return $html;
}
$html = (string) preg_replace('#</p>\s*<p[^>]*>#i', '<br><br>', $html);
$html = trim((string) preg_replace('#^<p[^>]*>|</p>$#i', '', $html));
return str_contains($html, '<') ? $html : html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* @param array<int, array<string, mixed>|array{_value: string}> $items
* @return array<int, array<string, mixed>|array{_value: string}>
*/
public static function toEditorJsonItems(array $items, bool $isStringArray): array
{
if ($isStringArray) {
return $items;
}
return array_map(function ($item) {
if (! is_array($item)) {
return $item;
}
$out = [];
foreach ($item as $key => $value) {
if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) {
$out[$key] = self::toEditor($value);
} else {
$out[$key] = $value;
}
}
return $out;
}, $items);
}
/**
* @param array<int, array<string, mixed>|array{_value: string}> $items
* @return array<int, array<string, mixed>|array{_value: string}>
*/
public static function fromEditorJsonItems(array $items, bool $isStringArray): array
{
if ($isStringArray) {
return $items;
}
return array_map(function ($item) {
if (! is_array($item)) {
return $item;
}
$out = [];
foreach ($item as $key => $value) {
if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) {
$out[$key] = self::unwrapParagraphs(self::fromEditor($value));
} else {
$out[$key] = $value;
}
}
return $out;
}, $items);
}
private static function spansWithClassToMarks(string $html): string
{
libxml_use_internal_errors(true);
$dom = new DOMDocument;
$wrapped = '<?xml encoding="utf-8"?><div id="cms-flux-root">'.$html.'</div>';
$dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$root = $dom->getElementById('cms-flux-root');
if (! $root instanceof DOMElement) {
return $html;
}
$xpath = new DOMXPath($dom);
$expression = '//span[contains(concat(" ", normalize-space(@class), " "), " text-secondary ")]';
$nodes = [];
foreach ($xpath->query($expression) ?? [] as $node) {
$nodes[] = $node;
}
usort($nodes, fn ($a, $b) => self::nodeDepth($b) <=> self::nodeDepth($a));
foreach ($nodes as $span) {
if (! $span instanceof DOMElement || $span->tagName !== 'span') {
continue;
}
$mark = $dom->createElement('mark');
while ($span->firstChild) {
$mark->appendChild($span->firstChild);
}
$span->parentNode?->replaceChild($mark, $span);
}
return self::extractInnerHtml($dom, $root);
}
private static function marksToSecondarySpans(string $html): string
{
libxml_use_internal_errors(true);
$dom = new DOMDocument;
$wrapped = '<?xml encoding="utf-8"?><div id="cms-flux-root">'.$html.'</div>';
$dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$root = $dom->getElementById('cms-flux-root');
if (! $root instanceof DOMElement) {
return $html;
}
$xpath = new DOMXPath($dom);
$nodes = [];
foreach ($xpath->query('//mark') ?? [] as $node) {
$nodes[] = $node;
}
usort($nodes, fn ($a, $b) => self::nodeDepth($b) <=> self::nodeDepth($a));
foreach ($nodes as $mark) {
if (! $mark instanceof DOMElement) {
continue;
}
$span = $dom->createElement('span');
$span->setAttribute('class', 'text-secondary');
while ($mark->firstChild) {
$span->appendChild($mark->firstChild);
}
$mark->parentNode?->replaceChild($span, $mark);
}
return self::extractInnerHtml($dom, $root);
}
private static function nodeDepth(DOMNode $node): int
{
$d = 0;
while ($node->parentNode) {
$d++;
$node = $node->parentNode;
}
return $d;
}
private static function extractInnerHtml(DOMDocument $dom, DOMElement $root): string
{
$html = '';
foreach ($root->childNodes as $child) {
$html .= $dom->saveHTML($child);
}
return $html;
}
}