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>
This commit is contained in:
Kevin Adametz 2026-10-12 06:58:48 +00:00
parent 07816dce19
commit 451e738c8b
20 changed files with 147 additions and 23 deletions

View file

@ -48,6 +48,30 @@ final class CmsFluxEditorHtmlTransformer
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}>
@ -92,7 +116,7 @@ final class CmsFluxEditorHtmlTransformer
$out = [];
foreach ($item as $key => $value) {
if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) {
$out[$key] = self::fromEditor($value);
$out[$key] = self::unwrapParagraphs(self::fromEditor($value));
} else {
$out[$key] = $value;
}

View file

@ -74,6 +74,37 @@ if (! function_exists('references_page_enabled')) {
}
}
if (! function_exists('cms_inline')) {
/**
* CMS-Text für die Ausgabe innerhalb einer Zeile oder eines Absatzes (12.10.2026): Klartext
* wird escaped, Editor-HTML auf Inline-Auszeichnung reduziert (fett, kursiv, Hervorhebung,
* Links, Zeilenumbrüche) – Absatz-Tags aus dem Editor erscheinen so nicht als Text.
*/
function cms_inline(mixed $value): \Illuminate\Support\HtmlString
{
$text = trim((string) $value);
if (! str_contains($text, '<')) {
return new \Illuminate\Support\HtmlString(e(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
}
$html = \App\Services\CmsFluxEditorHtmlTransformer::unwrapParagraphs(
\App\Services\CmsFluxEditorHtmlTransformer::fromEditor($text)
);
if (! str_contains($html, '<')) {
return new \Illuminate\Support\HtmlString(e($html));
}
$html = (string) preg_replace('#</(p|div|li|h[1-6])>\s*<\1[^>]*>#i', '<br><br>', $html);
$html = strip_tags($html, '<strong><b><em><i><span><br><a>');
// Nur harmlose Attribute: keine Event-Handler, keine javascript:-Links.
$html = (string) preg_replace('#\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
$html = (string) preg_replace('#href\s*=\s*(["\'])\s*javascript:[^"\']*\1#i', 'href="#"', $html);
return new \Illuminate\Support\HtmlString(trim($html));
}
}
if (! function_exists('whatsapp_link')) {
/**
* Klick-zum-Chat-Link, sofern im CMS eine Nummer hinterlegt ist (global.contact.whatsapp).

View file

@ -406,7 +406,7 @@ $saveEdit = function () {
$valueToSave = $this->editValue;
if ($this->editingFieldType === 'html') {
$valueToSave = CmsFluxEditorHtmlTransformer::fromEditor($valueToSave);
$valueToSave = CmsFluxEditorHtmlTransformer::unwrapParagraphs(CmsFluxEditorHtmlTransformer::fromEditor($valueToSave));
}
if ($this->editingField !== null) {

View file

@ -86,19 +86,19 @@
@if ($item['lead'] !== '')
<strong class="font-medium text-foreground">{{ $item['lead'] }}:</strong>
@endif
{{ $item['text'] }}
{!! cms_inline($item['text'] ?? '') !!}
</span>
</li>
@endforeach
</ul>
@else
<p class="text-muted-foreground leading-relaxed mt-4">{{ $block['text'] }}</p>
<p class="text-muted-foreground leading-relaxed mt-4">{!! cms_inline($block['text'] ?? '') !!}</p>
@endif
@endforeach
@if (!empty($section['quote']))
<figure class="mt-8 border-l-4 border-secondary pl-6">
<blockquote class="text-large leading-relaxed italic">„{{ $section['quote'] }}"</blockquote>
<blockquote class="text-large leading-relaxed italic">„{!! cms_inline($section['quote'] ?? '') !!}"</blockquote>
@if (!empty($section['quote_author']))
<figcaption class="text-sm text-muted-foreground mt-3">{{ $section['quote_author'] }}</figcaption>
@endif
@ -169,7 +169,7 @@
@foreach ($brand['services'] as $index => $service)
<div class="card-elevated rounded-2xl p-6" wire:key="service-{{ $index }}">
<h3 class="text-lg font-medium">{{ $service['title'] ?? '' }}</h3>
<p class="text-muted-foreground leading-relaxed mt-2">{{ $service['description'] ?? '' }}</p>
<p class="text-muted-foreground leading-relaxed mt-2">{!! cms_inline($service['description'] ?? '') !!}</p>
</div>
@endforeach
</div>

View file

@ -28,7 +28,7 @@
<h3 class="text-xl font-medium">{{ $world['title'] }}</h3>
@endif
<p class="text-muted-foreground leading-relaxed mt-2">
{{ $world['description'] }}
{!! cms_inline($world['description'] ?? '') !!}
</p>
</div>

View file

@ -24,7 +24,7 @@
x-on:keydown.escape.window="open && hide()">
<div>
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{{ $content['text'] ?? '' }}</p>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{!! cms_inline($content['text'] ?? '') !!}</p>
@if (!empty($content['note']))
<p class="text-sm text-muted-foreground mt-4">{{ $content['note'] }}</p>

View file

@ -65,7 +65,7 @@
<div class="px-6 pb-6">
<div class="border-t border-border pt-4">
<p class="text-muted-foreground leading-relaxed">
{{ $faq['answer'] }}
{!! cms_inline($faq['answer'] ?? '') !!}
</p>
</div>
</div>

View file

@ -9,7 +9,7 @@
<div class="absolute inset-0 flex items-end justify-center pb-12 px-6">
<div class="text-center slide-up delay-300">
<p class="text-xl lg:text-2xl font-medium text-white italic max-w-2xl leading-relaxed">
"{{ $content['quote'] }}"
"{!! cms_inline($content['quote'] ?? '') !!}"
</p>
@if (isset($content['author']))
<p class="text-sm text-white/70 mt-3">– {{ $content['author'] }}</p>

View file

@ -4,7 +4,7 @@
<div class="container-narrow">
<div class="border-t border-border pt-10 text-center">
<h2 class="text-xl font-medium">{!! $content['title'] ?? '' !!}</h2>
<p class="text-muted-foreground leading-relaxed mt-3 max-w-2xl mx-auto">{{ $content['text'] ?? '' }}</p>
<p class="text-muted-foreground leading-relaxed mt-3 max-w-2xl mx-auto">{!! cms_inline($content['text'] ?? '') !!}</p>
@if (!empty($content['link_text']))
<a href="{{ $content['link_url'] ?? '/immobilien' }}"

View file

@ -1,7 +1,7 @@
<section id="marken-cta" class="section-padding bg-secondary text-secondary-foreground">
<div class="container-narrow text-center spacing-section">
<h2 class="text-section-title text-white">{!! $content['title'] ?? '' !!}</h2>
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ $content['text'] ?? '' }}</p>
<p class="text-large max-w-2xl mx-auto leading-relaxed">{!! cms_inline($content['text'] ?? '') !!}</p>
<div class="pt-4 flex flex-wrap items-center justify-center gap-4">
<x-appointment-link :href="$content['button_link'] ?? '/contact'" class="btn-primary-accent px-8 py-4 text-lg">

View file

@ -3,7 +3,7 @@
<section id="wachstum" class="py-16 {{ $bg }}">
<div class="container-narrow text-center">
<h2 class="text-2xl font-medium">{!! $content['title'] ?? '' !!}</h2>
<p class="text-muted-foreground leading-relaxed mt-4">{{ $content['text'] ?? '' }}</p>
<p class="text-muted-foreground leading-relaxed mt-4">{!! cms_inline($content['text'] ?? '') !!}</p>
</div>
</section>
@endif

View file

@ -37,7 +37,7 @@
{!! renderHeroIcon($point['icon'] ?? 'user', 'outline', 'text-secondary-foreground') !!}
</div>
<h3 class="text-lg font-medium">{{ $point['title'] ?? '' }}</h3>
<p class="text-muted-foreground leading-relaxed mt-2">{{ $point['description'] ?? '' }}</p>
<p class="text-muted-foreground leading-relaxed mt-2">{!! cms_inline($point['description'] ?? '') !!}</p>
</div>
@endforeach
</div>

View file

@ -119,7 +119,7 @@
<div>
<h4 class="font-medium mb-1">{{ $step['title'] }}</h4>
@if (!empty($step['description']))
<p class="text-sm text-muted-foreground leading-relaxed">{{ $step['description'] }}</p>
<p class="text-sm text-muted-foreground leading-relaxed">{!! cms_inline($step['description'] ?? '') !!}</p>
@endif
</div>
</li>

View file

@ -32,7 +32,7 @@
@endif
@if(isset($card['description']))
<p class="text-muted-foreground leading-relaxed mt-4 text-center">
{{ $card['description'] }}
{!! cms_inline($card['description'] ?? '') !!}
</p>
@endif
</div>

View file

@ -53,7 +53,7 @@ function renderHeroIcon($iconName, $style = 'outline') {
@endif
@if(isset($value['description']))
<p class="text-muted-foreground leading-relaxed mt-2 text-center">
{{ $value['description'] }}
{!! cms_inline($value['description'] ?? '') !!}
</p>
@endif
</div>

View file

@ -8,7 +8,7 @@
<div class="lg:col-span-3 slide-right delay-200">
<x-web.hero-eyebrow :text="$content['eyebrow'] ?? null" class="mb-6" />
<h1 class="text-hero-alt">{!! $content['title'] ?? '' !!}</h1>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{{ $content['text'] ?? '' }}</p>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{!! cms_inline($content['text'] ?? '') !!}</p>
<x-web.hero-trust :items="$content['stats'] ?? []" class="mt-8" />
</div>
@ -23,7 +23,7 @@
<div class="max-w-3xl slide-right delay-200">
<x-web.hero-eyebrow :text="$content['eyebrow'] ?? null" class="mb-6" />
<h1 class="text-hero-alt">{!! $content['title'] ?? '' !!}</h1>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{{ $content['text'] ?? '' }}</p>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{!! cms_inline($content['text'] ?? '') !!}</p>
<x-web.hero-trust :items="$content['stats'] ?? []" class="mt-8" />
</div>
@endif

View file

@ -3,7 +3,7 @@
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@if (!empty($content['text']))
<p class="text-large text-muted-foreground leading-relaxed mt-4">{{ $content['text'] }}</p>
<p class="text-large text-muted-foreground leading-relaxed mt-4">{!! cms_inline($content['text'] ?? '') !!}</p>
@endif
</div>
@ -27,7 +27,7 @@
<p class="text-xs uppercase tracking-wider text-clay mt-1">{{ $project['meta'] }}</p>
@endif
@if (!empty($project['description']))
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{{ $project['description'] }}</p>
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{!! cms_inline($project['description'] ?? '') !!}</p>
@endif
@if (!empty($project['gallery']))
{{-- Weitere Bilder der Referenz als kleine Reihe --}}

View file

@ -33,7 +33,7 @@
@endif
</h3>
@if (!empty($logo['text']))
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{{ $logo['text'] }}</p>
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{!! cms_inline($logo['text'] ?? '') !!}</p>
@endif
</div>
</{{ $url ? 'a' : 'div' }}>

View file

@ -23,7 +23,7 @@
@endif
<span class="font-medium text-center leading-snug">
{{ $content['text'] ?? '' }}
{!! cms_inline($content['text'] ?? '') !!}
</span>
@if (isset($content['link_text']) && isset($content['link_url']))

View file

@ -0,0 +1,69 @@
<?php
use App\Models\User;
use App\Services\CmsFluxEditorHtmlTransformer;
use Database\Seeders\CmsContentSeeder;
use FluxCms\Core\Models\CmsContent;
use Livewire\Volt\Volt;
/**
* Der Rich-Text-Editor packt eingefügten Text in <p>; auf der Website erschienen die Tags als
* Text, z. B. in der FAQ (12.10.2026).
*/
it('stores pasted text from the editor without paragraph tags', function (string $editor, string $stored) {
expect(CmsFluxEditorHtmlTransformer::unwrapParagraphs($editor))->toBe($stored);
})->with([
'ein Absatz' => ['<p>Eingefügter Text</p>', 'Eingefügter Text'],
'Sonderzeichen' => ['<p>Planung &amp; Montage</p>', 'Planung & Montage'],
'fett' => ['<p>Mit <strong>fett</strong></p>', 'Mit <strong>fett</strong>'],
'zwei Absätze' => ['<p>Eins</p><p>Zwei</p>', 'Eins<br><br>Zwei'],
'leere Absätze' => ['<p>Text</p><p></p><p><br></p>', 'Text'],
'Blockinhalt bleibt' => ['<h2>Titel</h2><p>Text</p>', '<h2>Titel</h2><p>Text</p>'],
'schon Klartext' => ['Ohne Tags', 'Ohne Tags'],
]);
it('renders cms text inline: plain text escaped, editor html without paragraphs, nothing unsafe', function (string $value, string $html) {
expect((string) cms_inline($value))->toBe($html);
})->with([
['Planung & Montage', 'Planung &amp; Montage'],
['<p>Schon gespeichert</p>', 'Schon gespeichert'],
['<p>Mit <mark>Hervorhebung</mark></p>', 'Mit <span class="text-secondary">Hervorhebung</span>'],
['<p>a</p><p>b</p>', 'a<br><br>b'],
['<a href="javascript:alert(1)" onclick="x()">Link</a>', '<a href="#">Link</a>'],
['<p>&lt;script&gt;</p>', ''],
]);
it('saves a pasted faq answer without p tags and shows it cleanly on the page', function () {
$this->actingAs(User::factory()->create());
$this->seed(CmsContentSeeder::class);
$row = CmsContent::query()->where('group', 'faq')->where('key', 'faq')->first();
$component = Volt::test('admin.cms.content-index')
->call('startFieldEdit', $row->id, 'questions');
$component->set('jsonItems.0.answer', '<p>Neue Antwort aus der Zwischenablage</p>')
->call('saveJsonModal');
expect($row->refresh()->getTranslation('value', 'de')['questions'][0]['answer'])->toBe('Neue Antwort aus der Zwischenablage');
$page = $this->get('/faq')->assertSuccessful()->getContent();
expect(substr($page, strpos($page, 'id="faq"')))->toContain('Neue Antwort aus der Zwischenablage')
->not->toContain('&lt;p&gt;');
});
it('shows answers that were already stored with p tags without the tags', function () {
$this->seed(CmsContentSeeder::class);
$row = CmsContent::query()->where('group', 'faq')->where('key', 'faq')->first();
$value = $row->getTranslation('value', 'de');
$value['questions'][0]['answer'] = '<p>Bereits gespeichert mit Absatz</p>';
$row->setTranslation('value', 'de', $value)->save();
app(FluxCms\Core\Services\CmsContentService::class)->clearCache('faq');
$page = $this->get('/faq')->assertSuccessful()->getContent();
$section = substr($page, strpos($page, 'id="faq"'));
expect($section)->toContain('Bereits gespeichert mit Absatz')
->not->toContain('&lt;p&gt;')
->not->toContain('<p>Bereits');
});