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:
parent
624fca68a8
commit
bcac326814
4 changed files with 93 additions and 4 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ?? '';
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue