Preserve WooCommerce HTML in product descriptions with a rich text editor.
Deploy Ladill Woo Manager / deploy (push) Successful in 1m16s
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:
@@ -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 !== '');
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user