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:
parent
07816dce19
commit
451e738c8b
20 changed files with 147 additions and 23 deletions
|
|
@ -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| |<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' }}"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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']))
|
||||
|
|
|
|||
69
tests/Feature/CmsEditorParagraphsTest.php
Normal file
69
tests/Feature/CmsEditorParagraphsTest.php
Normal 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 & 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 & 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><script></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('<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'] = '<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('<p>')
|
||||
->not->toContain('<p>Bereits');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue