Preserve WooCommerce HTML in product descriptions with a rich text editor.
Deploy Ladill Woo Manager / deploy (push) Successful in 1m16s

Stop stripping tags on sync so paragraphs and bold book titles round-trip to Woo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-07 00:47:01 +00:00
co-authored by Cursor
parent 085645fd0f
commit 551473f8e5
10 changed files with 192 additions and 19 deletions
+4 -3
View File
@@ -5,6 +5,7 @@ namespace App\Services\Woo;
use App\Models\WooCategory;
use App\Models\WooProduct;
use App\Models\WooStore;
use App\Support\WooHtml;
class CatalogWriteService
{
@@ -112,8 +113,8 @@ class CatalogWriteService
'sale_price' => $this->majorPrice($input['sale_price'] ?? null),
'manage_stock' => (bool) ($input['manage_stock'] ?? false),
'stock_quantity' => isset($input['stock_quantity']) ? (int) $input['stock_quantity'] : null,
'short_description' => $input['short_description'] ?? null,
'description' => $input['description'] ?? null,
'short_description' => WooHtml::sanitize($input['short_description'] ?? null),
'description' => WooHtml::sanitize($input['description'] ?? null),
'categories' => $categories,
'images' => $input['images'] ?? null,
], fn ($value) => $value !== null && $value !== '');
@@ -127,7 +128,7 @@ class CatalogWriteService
return array_filter([
'name' => (string) ($input['name'] ?? ''),
'slug' => $input['slug'] ?? null,
'description' => $input['description'] ?? null,
'description' => WooHtml::sanitize($input['description'] ?? null),
'parent' => $parent > 0 ? $parent : 0,
], fn ($value) => $value !== null && $value !== '');
}
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Services\Woo;
use App\Models\WooCategory;
use App\Models\WooStore;
use App\Support\WooHtml;
use Carbon\Carbon;
class CategoryIngestService
@@ -27,7 +28,7 @@ class CategoryIngestService
'parent_external_id' => $parent > 0 ? $parent : null,
'name' => (string) ($payload['name'] ?? 'Category'),
'slug' => (string) ($payload['slug'] ?? 'category-'.$externalId),
'description' => $this->nullableString($payload['description'] ?? null),
'description' => WooHtml::sanitize($payload['description'] ?? null),
'product_count' => max(0, (int) ($payload['count'] ?? 0)),
'wc_updated_at' => $this->updatedAt($payload),
'metadata' => [
+4 -3
View File
@@ -4,6 +4,7 @@ namespace App\Services\Woo;
use App\Models\WooProduct;
use App\Models\WooStore;
use App\Support\WooHtml;
use Carbon\Carbon;
class ProductIngestService
@@ -54,8 +55,8 @@ class ProductIngestService
'manage_stock' => (bool) ($payload['manage_stock'] ?? false),
'category_external_ids' => $categoryIds,
'images' => $images,
'short_description' => $this->nullableString($payload['short_description'] ?? null),
'description' => $this->nullableString($payload['description'] ?? null),
'short_description' => WooHtml::sanitize($payload['short_description'] ?? null),
'description' => WooHtml::sanitize($payload['description'] ?? null),
'wc_updated_at' => $this->updatedAt($payload),
'metadata' => [
'permalink' => $payload['permalink'] ?? null,
@@ -92,7 +93,7 @@ class ProductIngestService
private function nullableString(mixed $value): ?string
{
$text = trim(strip_tags((string) $value));
$text = trim((string) $value);
return $text === '' ? null : $text;
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Support;
class WooHtml
{
private const ALLOWED_TAGS = '<p><br><strong><b><em><i><ul><ol><li><a>';
public static function sanitize(mixed $value): ?string
{
if ($value === null) {
return null;
}
$html = trim((string) $value);
if ($html === '') {
return null;
}
if ($html === strip_tags($html)) {
return self::plainToHtml($html);
}
$clean = trim(strip_tags($html, self::ALLOWED_TAGS));
return $clean === '' ? null : $clean;
}
public static function normalizeForEditor(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
if ($value === strip_tags($value)) {
return self::plainToHtml($value) ?? '';
}
return $value;
}
public static function plainToHtml(string $plain): ?string
{
$plain = str_replace(["\r\n", "\r"], "\n", trim($plain));
if ($plain === '') {
return null;
}
$parts = preg_split("/\n\s*\n/", $plain) ?: [];
if (count($parts) === 1 && str_contains($plain, "\n")) {
$parts = explode("\n", $plain);
}
$paragraphs = array_values(array_filter(
array_map('trim', $parts),
fn (string $part) => $part !== '',
));
if ($paragraphs === []) {
return null;
}
return implode('', array_map(
fn (string $paragraph) => '<p>'.self::escape($paragraph).'</p>',
$paragraphs,
));
}
private static function escape(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}
+2
View File
@@ -2,6 +2,7 @@ import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
import { registerWooRichText } from './woo-rich-text';
registerLadillModalHelpers();
registerLadillSearchShortcut();
@@ -17,6 +18,7 @@ window.qrcode = qrcode;
Alpine.plugin(collapse);
registerLadillClipboard(Alpine);
registerWooRichText(Alpine);
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
+21
View File
@@ -0,0 +1,21 @@
export function registerWooRichText(Alpine) {
Alpine.data('wooRichText', (initial = '') => ({
html: initial,
init() {
this.$refs.editor.innerHTML = this.html || '';
this.sync();
},
sync() {
this.html = this.$refs.editor.innerHTML.trim();
this.$refs.input.value = this.html;
},
format(command) {
document.execCommand(command, false, null);
this.sync();
this.$refs.editor.focus();
},
}));
}
@@ -0,0 +1,37 @@
@props([
'name',
'label',
'value' => '',
'minHeight' => '6rem',
'help' => null,
])
@php
$editorValue = \App\Support\WooHtml::normalizeForEditor(old($name, $value));
@endphp
<div {{ $attributes->merge(['class' => 'mt-4']) }}>
<label class="block text-sm font-medium text-slate-700">{{ $label }}</label>
@if($help)
<p class="mt-0.5 text-xs text-slate-500">{{ $help }}</p>
@endif
<div x-data="wooRichText(@js($editorValue))" class="mt-1">
<div class="flex items-center gap-1 rounded-t-xl border border-b-0 border-slate-200 bg-slate-50 px-2 py-1.5">
<button type="button" @click="format('bold')"
class="rounded-md px-2 py-0.5 text-xs font-bold text-slate-600 hover:bg-white hover:text-slate-900"
title="Bold">B</button>
<button type="button" @click="format('italic')"
class="rounded-md px-2 py-0.5 text-xs italic text-slate-600 hover:bg-white hover:text-slate-900"
title="Italic">I</button>
</div>
<div
x-ref="editor"
contenteditable="true"
@input="sync"
style="min-height: {{ $minHeight }}"
class="w-full rounded-b-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:font-semibold [&_em]:italic"
></div>
<input type="hidden" x-ref="input" name="{{ $name }}" :value="html">
</div>
</div>
@@ -41,10 +41,10 @@
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="4" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('description', $category->description) }}</textarea>
</div>
<x-woo.rich-text name="description" label="Description"
:value="$category->description"
min-height="6rem"
class="!mt-0" />
<div class="flex items-center justify-end gap-3">
<a href="{{ route('woo.categories.index') }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</a>
+8 -8
View File
@@ -77,15 +77,15 @@
</div>
@endif
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700">Short description</label>
<textarea name="short_description" rows="3" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('short_description', $product->short_description) }}</textarea>
</div>
<x-woo.rich-text name="short_description" label="Short description"
:value="$product->short_description"
min-height="4.5rem"
help="Shown on product listings. Bold the book or product name like in WooCommerce." />
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="6" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('description', $product->description) }}</textarea>
</div>
<x-woo.rich-text name="description" label="Description"
:value="$product->description"
min-height="12rem"
help="Full product page copy. Use separate paragraphs for each block of text." />
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-100 pt-6">
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Tests\Unit;
use App\Support\WooHtml;
use PHPUnit\Framework\TestCase;
class WooHtmlTest extends TestCase
{
public function test_sanitize_preserves_paragraphs_and_bold(): void
{
$html = '<p>Intro</p><p>In <strong>In Jail In My Own Society</strong>, author writes.</p>';
$this->assertSame(
'<p>Intro</p><p>In <strong>In Jail In My Own Society</strong>, author writes.</p>',
WooHtml::sanitize($html),
);
}
public function test_plain_to_html_wraps_legacy_paragraphs(): void
{
$plain = "First paragraph\r\nSecond paragraph\r\nThird paragraph";
$this->assertSame(
'<p>First paragraph</p><p>Second paragraph</p><p>Third paragraph</p>',
WooHtml::plainToHtml($plain),
);
}
public function test_normalize_for_editor_upgrades_plain_text(): void
{
$this->assertSame(
'<p>Line one</p><p>Line two</p>',
WooHtml::normalizeForEditor("Line one\nLine two"),
);
}
}