DEV Community

Cover image for Bolting a rich-text editor onto a column full of plain text
Nasrul Hazim
Nasrul Hazim

Posted on

Bolting a rich-text editor onto a column full of plain text

Today's job: let people write email bodies with bold text, headings, links and inline images instead of a bare <textarea>. Straightforward feature. The interesting part is that the column already had a few hundred rows of plain text in it, written by the previous version of the same screen — and nobody wants a data migration on a body column they can't fully test.

So the rule I set myself: no migration. The database keeps whatever's in it, and the app figures out the format at read time.

That one decision drives almost every other choice in this post.

The read-time format sniff

Everything hangs off one predicate:

function email_body_is_html(string $body): bool
{
    return (bool) preg_match(
        '/<(div|p|br|strong|em|b|i|del|ul|ol|li|h[1-6]|a|img|figure|blockquote|pre)[\s>\/]/i',
        $body
    );
}
Enter fullscreen mode Exit fullscreen mode

Yes, it's a regex against HTML, and yes, I know. But it isn't parsing HTML — it's answering one question: did a rich editor produce this? The tag list is exactly the set the editor can emit, and the [\s>\/] guard is what stops I <bought three from being read as a bold tag. For a boolean discriminator between "text a human typed in a textarea" and "markup a WYSIWYG produced", that's the right amount of machinery.

From that predicate, three renderers — and they're the whole compatibility layer:

// For the mail template
function email_body_html(string $body): string
{
    return email_body_is_html($body)
        ? app(EmailHtmlSanitizer::class)->sanitize($body)
        : nl2br(e($body));
}

// For loading into the editor
function email_body_editor_html(string $body): string
{
    return email_body_is_html($body) ? $body : nl2br(e($body));
}

// For list previews and the activity timeline
function email_body_text(string $body): string
{
    if (! email_body_is_html($body)) {
        return $body;
    }

    $withBreaks = preg_replace('/<(br|\/div|\/p|\/li|\/h[1-6])[^>]*>/i', "\n", $body) ?? $body;

    return trim(html_entity_decode(strip_tags($withBreaks), ENT_QUOTES | ENT_HTML5));
}
Enter fullscreen mode Exit fullscreen mode

That last one is the function everybody forgets. The moment your body column can hold HTML, every place that previously did Str::limit($body, 80) starts showing people <div>Hi {{first_n…. Timeline entries, table previews, notification digests. Write the plain-text rendition on day one, not after someone screenshots it.

The gotcha that ate an hour

The editor I used is Trix, which reads its initial value out of a hidden <input> — and parses it as HTML. Which means a legacy plain-text body, full of \n, arrives in the editor as one gigantic paragraph. All the line breaks silently gone. Not an error, not a warning; the user just opens an old email and finds their formatting collapsed.

Hence email_body_editor_html(): escape it, nl2br it, then hand it over. Text going into an HTML parser has to be HTML first.

Here's the part that actually cost the hour, though. That helper runs server-side at render. But the property can also change after mount — pick a template from a dropdown and Livewire pushes a new body down to an already-booted editor. That value never passes through the Blade helper. So the same logic has to exist a second time, in JavaScript:

// Mirror of email_body_editor_html() for values arriving from the server
// after mount (e.g. template prefill).
asHtml(v) {
    return /<(div|p|br|strong|em|b|i|del|ul|ol|li|h[1-6]|a|img|figure|blockquote|pre)[\s>\/]/i.test(v)
        ? v
        : v.replace(/&/g, '&amp;')
           .replace(/</g, '&lt;')
           .replace(/>/g, '&gt;')
           .replace(/\n/g, '<br>');
}
Enter fullscreen mode Exit fullscreen mode

Duplicated logic is a smell and I'm not going to pretend otherwise. But the alternative — round-tripping every prefill through the server purely to format it — buys a network hop to avoid twelve lines. I took the duplication and put a comment on both sides pointing at the other. If one of them changes and the other doesn't, the bug is "template prefill loses line breaks", which is at least loud enough to find.

Wiring a non-Livewire editor into Livewire

Third-party editors and Livewire's DOM diffing don't get along: Livewire morphs the DOM, the editor has its own idea of what's in there, and they fight. The shape that works:

<div
    wire:ignore
    x-data="{
        value: @entangle($model),
        init() {
            const input = this.$refs.input;
            const editor = this.$refs.editor;

            editor.addEventListener('trix-change', () => {
                this.value = input.value;
            });

            this.$watch('value', (v) => {
                if ((v ?? '') !== input.value && editor.editor) {
                    editor.editor.loadHTML(this.asHtml(v ?? ''));
                }
            });
        },
        asHtml(v) { /* … as above … */ },
    }"
>
    <input type="hidden" id="{{ $editorId }}" x-ref="input" value="{{ $initial }}">
    <trix-editor x-ref="editor" input="{{ $editorId }}"
                 data-upload-url="{{ route('email-assets.store') }}"></trix-editor>
</div>
Enter fullscreen mode Exit fullscreen mode

Four things doing real work there:

  • wire:ignore — Livewire keeps its hands off the subtree entirely. The editor owns that DOM.
  • @entangle — the two-way bridge that survives wire:ignore. Editor changes push up; server changes push down.
  • The !== guard in $watch — without it, editor → Alpine → watcher → loadHTML → editor is an infinite loop that also eats your cursor position on every keystroke.
  • value rendered server-side — the editor reads its hidden input before Alpine boots, so an Alpine-bound :value (or the entangled value alone) arrives too late. The initial paint has to be in the HTML.

One more, learned the hard way: when the bound property changes identity — editing step 3 of an automation after step 2 — wrap the component in an @if with a wire:key. Otherwise Livewire morphs the wrapper, the editor instance survives with the previous step's content, and you overwrite the wrong record.

Sanitize twice

Now the security half. User-authored HTML goes into an email template, so it gets sanitized. Symfony's HTML sanitizer with an allow-list:

$config = (new HtmlSanitizerConfig)
    ->allowElement('p')
    ->allowElement('br')
    ->allowElement('strong')
    ->allowElement('em')
    ->allowElement('h1')
    ->allowElement('h2')
    ->allowElement('ul')
    ->allowElement('li')
    ->allowElement('blockquote')
    ->allowElement('a', ['href', 'title'])
    ->allowElement('img', ['src', 'alt', 'width', 'height'])
    ->allowLinkSchemes(['http', 'https', 'mailto'])
    ->allowMediaSchemes(['http', 'https'])
    ->withMaxInputLength(200_000);
Enter fullscreen mode Exit fullscreen mode

Two things worth stealing here. First, the allow-list is exactly what the editor can produce — not a generic "safe HTML" list. If the editor can't emit <table>, nothing legitimate needs <table>, so it isn't on the list. Deriving the allow-list from your own producer instead of from the spec makes it dramatically smaller.

Second, that same subset is roughly the HTML that renders reliably across email clients anyway. The security constraint and the deliverability constraint happen to point the same direction. Nice when that happens.

The bit that's easy to miss: sanitizing on save is not enough. These bodies contain {{variable}} tokens interpolated at send time with contact data — data that came from a public web form. Sanitize at save, and the interpolation splices unsanitized text into already-blessed HTML afterwards. So email_body_html() sanitizes again at render. Defence in depth isn't a slogan here; there's a real second write into the string after the first pass.

The tokens themselves survive fine, incidentally — {{first_name}} lives in a text node, and a sanitizer that strips elements and attributes doesn't touch text nodes.

Uploads that anonymous recipients can actually load

Drop an image into the editor and Trix fires trix-attachment-add; the handler reads the data-upload-url attribute off the element and POSTs the file to an endpoint that returns the URL to embed:

public function __invoke(Request $request): JsonResponse
{
    abort_unless($request->user()->canAny(['email.create', 'email.update']), 403);

    $validated = $request->validate([
        'file' => ['required', 'file', 'max:10240', 'mimes:'.self::ACCEPTED_MIMES],
    ]);

    $asset = EmailAsset::create([
        'name'      => $validated['file']->getClientOriginalName(),
        'mime_type' => $validated['file']->getMimeType(),
        'size'      => $validated['file']->getSize(),
    ]);

    $asset->addMedia($validated['file'])
        ->toMediaCollection(EmailAsset::MEDIA_COLLECTION, 'public');

    return response()->json([
        'uuid' => $asset->uuid,
        'name' => $asset->name,
        'url'  => $asset->url(),
    ], 201);
}
Enter fullscreen mode Exit fullscreen mode

The 'public' disk is a deliberate decision, not laziness. Email recipients are anonymous by definition — there is no session, no signed URL you can meaningfully expire, and any auth check in front of that image means a broken-image icon in every inbox. So: public disk, unguessable identifier, tight mime allow-list, size cap. Upload is gated by permission; reading can't be.

Worth being explicit about that in a comment, because six months from now someone will see 'public' and "fix" it.

Testing it

The interesting assertions aren't "does the editor work" — they're the format boundary:

it('preserves line breaks when loading a legacy plain-text body', function () {
    $body = "Hi there,\n\nThanks for signing up.";

    expect(email_body_editor_html($body))
        ->toContain('<br')
        ->and(email_body_is_html($body))->toBeFalse();
});

it('leaves a rich body untouched on the way into the editor', function () {
    $body = '<div>Hi there,</div><div><br></div><div>Thanks.</div>';

    expect(email_body_editor_html($body))->toBe($body);
});

it('strips disallowed markup at render time', function () {
    $body = '<p>Hello <strong>you</strong></p><script>alert(1)</script>';

    expect(email_body_html($body))
        ->toContain('<strong>you</strong>')
        ->not->toContain('script');
});

it('renders a plain-text preview from a rich body', function () {
    expect(email_body_text('<div>Line one</div><div>Line two</div>'))
        ->toBe("Line one\nLine two");
});
Enter fullscreen mode Exit fullscreen mode

Four tests, no browser, and they cover every path through the compatibility layer. Because the layer is plain functions rather than something tangled into a component, testing it costs nothing — which is most of the argument for putting it there in the first place.

The takeaway

Bolting rich text onto a plain-text column is mostly not an editor problem. It's a format-boundary problem, and the boundary shows up in more places than you'd guess: the editor's initial value, the editor's post-mount value, the rendered email, the list preview, the timeline. Five places, five different correct answers, one predicate underneath.

Do the sniff-at-read-time version first. It's reversible, it's testable in isolation, and if you later decide you do want to normalise everything to HTML, you already own the exact function that would do the converting.

Next up: deciding whether the plain-text text/plain multipart alternative gets generated from email_body_text() or authored separately. Generated is less work and immediately wrong for anything with images.

Top comments (0)