Fix: Umbrueche bleiben beim Bearbeiten von Legacy-PMs erhalten

Bestehende (importierte) Pressemitteilungen speichern ihren Text als
Plaintext mit \n-Umbruechen. Beim Laden in den HTML-Editor (flux:editor)
kollabierten diese zu einem Fliesssatz, weil HTML Whitespace faltet.

Die Plaintext->HTML-Konvertierung (Doppelumbruch = Absatz, Einzelumbruch
= <br>) lag bisher nur im Anzeige-Pfad (render()). Sie ist jetzt als
toEditorHtml() wiederverwendbar und wird beim Laden des Editors genutzt;
die Link-Policy (rel/target) bleibt dabei dem Anzeige-Pfad vorbehalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-18 12:57:45 +00:00
parent 624fca68a8
commit bcac326814
4 changed files with 93 additions and 4 deletions

View file

@ -61,17 +61,46 @@ class PressReleaseHtmlSanitizer
return new HtmlString($this->linkPolicy->apply($this->clean($text)));
}
return new HtmlString($this->plainTextToParagraphs($text));
}
/**
* Liefert editierbares HTML für den Flux/Tiptap-Editor.
*
* Bereits gespeichertes HTML (Phase 7+) wird nur gesäubert. Legacy-
* Plaintext (ältere Importe) wird in <p>/<br> gewandelt — sonst kollabieren
* die Zeilenumbrüche beim Laden in den HTML-Editor zu einem Fließsatz.
* Anders als render() wird hier KEINE Link-Policy (rel/target) eingebrannt,
* da diese erst beim Anzeigen greift und nicht in den Editor gehört.
*/
public function toEditorHtml(?string $text): string
{
if ($text === null || trim($text) === '') {
return '';
}
if ($this->isHtml($text)) {
return $this->clean($text);
}
return $this->plainTextToParagraphs($text);
}
/**
* Wandelt Plaintext mit \n-Umbrüchen in <p>-Absätze (Doppelumbruch =
* neuer Absatz, Einzelumbruch = <br>) und escaped dabei HTML-Sonderzeichen.
*/
private function plainTextToParagraphs(string $text): string
{
$escaped = e($text);
$withBreaks = nl2br($escaped, false);
$paragraphs = preg_split('/(?:<br\s*\/?>\s*){2,}/i', $withBreaks) ?: [$withBreaks];
$html = collect($paragraphs)
return collect($paragraphs)
->map(fn (string $chunk): string => trim($chunk))
->filter()
->map(fn (string $chunk): string => '<p>'.$chunk.'</p>')
->implode('');
return new HtmlString($html);
}
/**

View file

@ -99,7 +99,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->categoryId = $pr->category_id;
$this->title = $pr->title;
$this->subtitle = $pr->subtitle ?? '';
$this->text = $pr->text;
// Legacy-Plaintext (ältere Importe) in editierbares HTML wandeln, sonst
// verschwinden die Zeilenumbrüche beim Laden in den Flux-Editor.
$this->text = app(PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text);
$this->keywords = $pr->keywords ?? '';
$this->backlinkUrl = $pr->backlink_url ?? '';
$this->boilerplateOverride = $pr->boilerplate_override ?? '';

View file

@ -59,6 +59,36 @@ test('editor shows the content score panel when a score exists', function () {
->assertSee('Noch 13 Punkte bis zur nächsten Stufe.');
});
test('mount converts legacy plain-text body to editable html so line breaks survive', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
'text' => "Erster Absatz mit Inhalt.\nZweite Zeile desselben Absatzes.\n\nNeuer Absatz nach Leerzeile.",
]);
$this->actingAs($customer);
$component = LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]);
$loaded = $component->get('text');
expect($loaded)->toContain('<p>');
expect($loaded)->toContain('Erster Absatz mit Inhalt.<br');
expect($loaded)->toContain('<p>Neuer Absatz nach Leerzeile.</p>');
});
test('mount leaves stored html body untouched in structure', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
'text' => '<p>Absatz eins.</p><p>Absatz zwei.</p>',
]);
$this->actingAs($customer);
$loaded = LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])->get('text');
expect($loaded)->toContain('<p>Absatz eins.</p>');
expect($loaded)->toContain('<p>Absatz zwei.</p>');
});
test('mount loads all Phase 7 fields and pivot contact', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease([

View file

@ -107,6 +107,34 @@ test('render returns sanitized html for stored html content', function () {
expect($html)->not->toContain('<script');
});
test('toEditorHtml converts legacy plain text to paragraphs and br tags', function () {
$plain = "Zeile eins\nZeile zwei\n\nNeuer Absatz";
$html = sanitizer()->toEditorHtml($plain);
expect($html)->toContain('<p>');
expect($html)->toContain('Zeile eins<br');
expect($html)->toContain('Zeile zwei');
expect($html)->toContain('<p>Neuer Absatz</p>');
});
test('toEditorHtml keeps stored html but strips dangerous tags without baking in rel attributes', function () {
$stored = '<p>Hallo</p><p><a href="https://example.test">Link</a></p><script>bad()</script>';
$html = sanitizer()->toEditorHtml($stored);
expect($html)->toContain('<p>Hallo</p>');
expect($html)->not->toContain('<script');
// Link-Policy (sponsored/noopener) gehört nicht in den Editor — die wird
// erst beim Anzeigen über render() gesetzt, nicht in den Editor-Inhalt.
expect($html)->not->toContain('sponsored');
});
test('toEditorHtml returns empty string for null and whitespace', function () {
expect(sanitizer()->toEditorHtml(null))->toBe('');
expect(sanitizer()->toEditorHtml(' '))->toBe('');
});
test('plainTextLength counts chars without html noise', function () {
expect(sanitizer()->plainTextLength('<p>Hallo Welt</p>'))->toBe(10);
expect(sanitizer()->plainTextLength('<p>Eins</p> <p>Zwei</p>'))->toBe(9);