From 451e738c8b0078195394b8bba4ef86c8ffa0d5a0 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 12 Oct 2026 06:58:48 +0000 Subject: [PATCH] CMS: Absatz-Tags aus dem Rich-Text-Editor erscheinen nicht mehr als Text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Editor packt eingefügten Text in

; 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

, mehrere mit

, Blockinhalte unverändert). cms_inline() gibt die Felder als bereinigtes Inline-HTML aus – auch schon gespeicherte Texte mit

erscheinen sauber, Fett und Hervorhebung werden dargestellt. Co-Authored-By: Claude Opus 5.5 --- app/Services/CmsFluxEditorHtmlTransformer.php | 26 ++++++- app/helpers.php | 31 +++++++++ .../admin/cms/content-index.blade.php | 2 +- .../sections/brand-detail.blade.php | 8 +-- .../sections/brand-worlds.blade.php | 2 +- .../sections/display-feed.blade.php | 2 +- .../web/components/sections/f-a-q.blade.php | 2 +- .../components/sections/image-break.blade.php | 2 +- .../sections/immobilien-teaser.blade.php | 2 +- .../components/sections/marken-cta.blade.php | 2 +- .../sections/marken-growth.blade.php | 2 +- .../sections/marken-network.blade.php | 2 +- .../sections/origin-section.blade.php | 2 +- .../components/sections/our-story.blade.php | 2 +- .../components/sections/our-values.blade.php | 2 +- .../components/sections/page-hero.blade.php | 4 +- .../sections/reference-projects.blade.php | 4 +- .../components/sections/sponsoring.blade.php | 2 +- .../components/ui/announcement-bar.blade.php | 2 +- tests/Feature/CmsEditorParagraphsTest.php | 69 +++++++++++++++++++ 20 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/CmsEditorParagraphsTest.php diff --git a/app/Services/CmsFluxEditorHtmlTransformer.php b/app/Services/CmsFluxEditorHtmlTransformer.php index 4e8d5d89..d059a621 100644 --- a/app/Services/CmsFluxEditorHtmlTransformer.php +++ b/app/Services/CmsFluxEditorHtmlTransformer.php @@ -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 `

`, mehrere durch `

` 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('#]*>(\s| |)*

#i', '', $html)); + + if ($html === '' || ! str_contains($html, '\s*]*>#i', '

', $html); + $html = trim((string) preg_replace('#^]*>|

$#i', '', $html)); + + return str_contains($html, '<') ? $html : html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + /** * @param array|array{_value: string}> $items * @return array|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; } diff --git a/app/helpers.php b/app/helpers.php index 16bfccfe..f67d75dc 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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('#\s*<\1[^>]*>#i', '

', $html); + $html = strip_tags($html, '
'); + // 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). diff --git a/resources/views/livewire/admin/cms/content-index.blade.php b/resources/views/livewire/admin/cms/content-index.blade.php index 4d818745..f25e5dd9 100644 --- a/resources/views/livewire/admin/cms/content-index.blade.php +++ b/resources/views/livewire/admin/cms/content-index.blade.php @@ -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) { diff --git a/resources/views/livewire/web/components/sections/brand-detail.blade.php b/resources/views/livewire/web/components/sections/brand-detail.blade.php index 4d085207..1fc3277d 100644 --- a/resources/views/livewire/web/components/sections/brand-detail.blade.php +++ b/resources/views/livewire/web/components/sections/brand-detail.blade.php @@ -86,19 +86,19 @@ @if ($item['lead'] !== '') {{ $item['lead'] }}: @endif - {{ $item['text'] }} + {!! cms_inline($item['text'] ?? '') !!}
@endforeach @else -

{{ $block['text'] }}

+

{!! cms_inline($block['text'] ?? '') !!}

@endif @endforeach @if (!empty($section['quote']))
-
„{{ $section['quote'] }}"
+
„{!! cms_inline($section['quote'] ?? '') !!}"
@if (!empty($section['quote_author']))
{{ $section['quote_author'] }}
@endif @@ -169,7 +169,7 @@ @foreach ($brand['services'] as $index => $service)

{{ $service['title'] ?? '' }}

-

{{ $service['description'] ?? '' }}

+

{!! cms_inline($service['description'] ?? '') !!}

@endforeach diff --git a/resources/views/livewire/web/components/sections/brand-worlds.blade.php b/resources/views/livewire/web/components/sections/brand-worlds.blade.php index 10c69010..abd6ea18 100644 --- a/resources/views/livewire/web/components/sections/brand-worlds.blade.php +++ b/resources/views/livewire/web/components/sections/brand-worlds.blade.php @@ -28,7 +28,7 @@

{{ $world['title'] }}

@endif

- {{ $world['description'] }} + {!! cms_inline($world['description'] ?? '') !!}

diff --git a/resources/views/livewire/web/components/sections/display-feed.blade.php b/resources/views/livewire/web/components/sections/display-feed.blade.php index 94345942..b62a5aee 100644 --- a/resources/views/livewire/web/components/sections/display-feed.blade.php +++ b/resources/views/livewire/web/components/sections/display-feed.blade.php @@ -24,7 +24,7 @@ x-on:keydown.escape.window="open && hide()">

{!! $content['title'] ?? '' !!}

-

{{ $content['text'] ?? '' }}

+

{!! cms_inline($content['text'] ?? '') !!}

@if (!empty($content['note']))

{{ $content['note'] }}

diff --git a/resources/views/livewire/web/components/sections/f-a-q.blade.php b/resources/views/livewire/web/components/sections/f-a-q.blade.php index 02a29bb9..dcd67f4b 100644 --- a/resources/views/livewire/web/components/sections/f-a-q.blade.php +++ b/resources/views/livewire/web/components/sections/f-a-q.blade.php @@ -65,7 +65,7 @@

- {{ $faq['answer'] }} + {!! cms_inline($faq['answer'] ?? '') !!}

diff --git a/resources/views/livewire/web/components/sections/image-break.blade.php b/resources/views/livewire/web/components/sections/image-break.blade.php index 2885e62e..2ee52c63 100644 --- a/resources/views/livewire/web/components/sections/image-break.blade.php +++ b/resources/views/livewire/web/components/sections/image-break.blade.php @@ -9,7 +9,7 @@

- "{{ $content['quote'] }}" + "{!! cms_inline($content['quote'] ?? '') !!}"

@if (isset($content['author']))

– {{ $content['author'] }}

diff --git a/resources/views/livewire/web/components/sections/immobilien-teaser.blade.php b/resources/views/livewire/web/components/sections/immobilien-teaser.blade.php index a2045fa3..665f57b3 100644 --- a/resources/views/livewire/web/components/sections/immobilien-teaser.blade.php +++ b/resources/views/livewire/web/components/sections/immobilien-teaser.blade.php @@ -4,7 +4,7 @@
diff --git a/resources/views/livewire/web/components/sections/origin-section.blade.php b/resources/views/livewire/web/components/sections/origin-section.blade.php index dcaf0d18..9a47000f 100644 --- a/resources/views/livewire/web/components/sections/origin-section.blade.php +++ b/resources/views/livewire/web/components/sections/origin-section.blade.php @@ -119,7 +119,7 @@

{{ $step['title'] }}

@if (!empty($step['description'])) -

{{ $step['description'] }}

+

{!! cms_inline($step['description'] ?? '') !!}

@endif
diff --git a/resources/views/livewire/web/components/sections/our-story.blade.php b/resources/views/livewire/web/components/sections/our-story.blade.php index 633caacc..be509466 100644 --- a/resources/views/livewire/web/components/sections/our-story.blade.php +++ b/resources/views/livewire/web/components/sections/our-story.blade.php @@ -32,7 +32,7 @@ @endif @if(isset($card['description']))

- {{ $card['description'] }} + {!! cms_inline($card['description'] ?? '') !!}

@endif
diff --git a/resources/views/livewire/web/components/sections/our-values.blade.php b/resources/views/livewire/web/components/sections/our-values.blade.php index fb058f8b..a2f645e2 100644 --- a/resources/views/livewire/web/components/sections/our-values.blade.php +++ b/resources/views/livewire/web/components/sections/our-values.blade.php @@ -53,7 +53,7 @@ function renderHeroIcon($iconName, $style = 'outline') { @endif @if(isset($value['description']))

- {{ $value['description'] }} + {!! cms_inline($value['description'] ?? '') !!}

@endif
diff --git a/resources/views/livewire/web/components/sections/page-hero.blade.php b/resources/views/livewire/web/components/sections/page-hero.blade.php index 3a51de79..5929c033 100644 --- a/resources/views/livewire/web/components/sections/page-hero.blade.php +++ b/resources/views/livewire/web/components/sections/page-hero.blade.php @@ -8,7 +8,7 @@

{!! $content['title'] ?? '' !!}

-

{{ $content['text'] ?? '' }}

+

{!! cms_inline($content['text'] ?? '') !!}

@@ -23,7 +23,7 @@

{!! $content['title'] ?? '' !!}

-

{{ $content['text'] ?? '' }}

+

{!! cms_inline($content['text'] ?? '') !!}

@endif diff --git a/resources/views/livewire/web/components/sections/reference-projects.blade.php b/resources/views/livewire/web/components/sections/reference-projects.blade.php index 357addc5..eeff5644 100644 --- a/resources/views/livewire/web/components/sections/reference-projects.blade.php +++ b/resources/views/livewire/web/components/sections/reference-projects.blade.php @@ -3,7 +3,7 @@

{!! $content['title'] ?? '' !!}

@if (!empty($content['text'])) -

{{ $content['text'] }}

+

{!! cms_inline($content['text'] ?? '') !!}

@endif
@@ -27,7 +27,7 @@

{{ $project['meta'] }}

@endif @if (!empty($project['description'])) -

{{ $project['description'] }}

+

{!! cms_inline($project['description'] ?? '') !!}

@endif @if (!empty($project['gallery'])) {{-- Weitere Bilder der Referenz als kleine Reihe --}} diff --git a/resources/views/livewire/web/components/sections/sponsoring.blade.php b/resources/views/livewire/web/components/sections/sponsoring.blade.php index 017bc189..0256dcc2 100644 --- a/resources/views/livewire/web/components/sections/sponsoring.blade.php +++ b/resources/views/livewire/web/components/sections/sponsoring.blade.php @@ -33,7 +33,7 @@ @endif @if (!empty($logo['text'])) -

{{ $logo['text'] }}

+

{!! cms_inline($logo['text'] ?? '') !!}

@endif
diff --git a/resources/views/livewire/web/components/ui/announcement-bar.blade.php b/resources/views/livewire/web/components/ui/announcement-bar.blade.php index 26d89f3c..fb38f733 100644 --- a/resources/views/livewire/web/components/ui/announcement-bar.blade.php +++ b/resources/views/livewire/web/components/ui/announcement-bar.blade.php @@ -23,7 +23,7 @@ @endif - {{ $content['text'] ?? '' }} + {!! cms_inline($content['text'] ?? '') !!} @if (isset($content['link_text']) && isset($content['link_url'])) diff --git a/tests/Feature/CmsEditorParagraphsTest.php b/tests/Feature/CmsEditorParagraphsTest.php new file mode 100644 index 00000000..00727bb6 --- /dev/null +++ b/tests/Feature/CmsEditorParagraphsTest.php @@ -0,0 +1,69 @@ +; 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' => ['

Eingefügter Text

', 'Eingefügter Text'], + 'Sonderzeichen' => ['

Planung & Montage

', 'Planung & Montage'], + 'fett' => ['

Mit fett

', 'Mit fett'], + 'zwei Absätze' => ['

Eins

Zwei

', 'Eins

Zwei'], + 'leere Absätze' => ['

Text


', 'Text'], + 'Blockinhalt bleibt' => ['

Titel

Text

', '

Titel

Text

'], + '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 & Montage'], + ['

Schon gespeichert

', 'Schon gespeichert'], + ['

Mit Hervorhebung

', 'Mit Hervorhebung'], + ['

a

b

', 'a

b'], + ['
Link', 'Link'], + ['

<script>

', ''], +]); + +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', '

Neue Antwort aus der Zwischenablage

') + ->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('<p>'); +}); + +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'] = '

Bereits gespeichert mit Absatz

'; + $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('<p>') + ->not->toContain('

Bereits'); +});