tonysm / rich-text-laravel
Integrates Trix content with Laravel
Installs: 25 407
Dependents: 1
Suggesters: 0
Security: 0
Stars: 349
Watchers: 5
Forks: 15
Open Issues: 2
Requires
- php: ^8.2
- illuminate/contracts: ^10.0|^11.0
- spatie/laravel-package-tools: ^1.9.2
- tonysm/globalid-laravel: ^1.1
Requires (Dev)
- laravel/pint: ^1.10
- livewire/livewire: ^3.4
- nunomaduro/collision: ^6.0|^8.0
- orchestra/testbench: ^8.21|^9.0
- orchestra/workbench: ^8.0|^9.0
- phpunit/phpunit: ^10.5
- symfony/html-sanitizer: ^7.0
This package is auto-updated.
Last update: 2024-10-31 15:29:43 UTC
README
Integrates the Trix Editor with Laravel. Inspired by the Action Text gem from Rails.
Installation
You can install the package via composer:
composer require tonysm/rich-text-laravel
Then, you may install it running:
php artisan richtext:install
Next, you may run the migration:
php artisan migrate
Ensure the styles Blade component were added to your layouts:
<x-rich-text::styles />
Alternatively, if you're using Breeze (or TailwindCSS), you may prefer the tweaked theme:
<x-rich-text::styles theme="richtextlaravel" />
Finally, you may now use the published input Blade component on your forms like so:
<x-trix-input id="bio" name="bio" />
That's it!
Overview
We extract attachments before saving the rich text field (which uses Trix) in the database and minimize the content for storage. Attachments are replaced with rich-text-attachment
tags. Attachments from attachable models have a sgid
attribute, which should globally identify them in your app.
When storing images directly (say, for a simple image uploading where you don't have a model for representing that attachment in your application), we'll fill the rich-text-attachment
with all the attachment's properties needded to render that image again. Storing a minimized (canonical) version of the rich text content means we don't store the inner contents of the attachment tags, only the metadata needded to render it again when needed.
There are two ways of using the package:
- With the recommended database structure where all rich text content will be stored outside of the model that has rich text content (recommended); and
- Only using the
AsRichTextContent
trait to cast a rich text content field on any model, on any table you want.
Below, we cover each usage way. It's recommended that you at least read the Trix documentation at some point to get an overview of the client-side of it.
The RichText Model
The recommended way is to keep the rich text content outside of the model itself. This will keep the models lean when you're manipulating them, and you can (eagerly or lazily) load the rich text fields only where you need the rich text content.
Here's how you would have two rich text fields on a Post model, say you need one for the body of the content and another one for internal notes you may have:
use Tonysm\RichTextLaravel\Models\Traits\HasRichText; class Post extends Model { use HasRichText; protected $guarded = []; protected $richTextAttributes = [ 'body', 'notes', ]; }
This trait will create dynamic relationships on the Post model, one for each field. These relationships will be called: richText{FieldName}
and you may define the fields using underscore, so if you had a internal_notes
field, that would have a richTextInternalNotes
relationship added on the model.
For a better DX, the trait will also add a custom cast for the body
and notes
fields on the Post model to forward setting/getting operations to the relationship, since these fields will NOT be stored in the posts table. This means that you can use the Post model like this:
$post = Post::create(['body' => $body, 'notes' => $notes]);
And you can interact with the rich text fields just like you would with any regular field on the Post model:
$post->body->render();
Again, there's no body
or notes
fields on the Post model, these virtual fields will forward interactions to the relationship of that field. This means that when you interact with these fields, you're actually interacting with an instance of the RichText
model. That model will have a body
field that holds the rich text content. This field is then casted to an instance of the Content
class. Calls to the RichText model will be forwarded to the body
field on the RichText
model, which is an instance of the Content
class. This means that instead of:
$post->body->body->attachments();
Where the first "body" is the virtual field which will be an instance of the RichText model and the second "body" is the rich text content field on that model, which is an instance of the Content
class, you can do:
$post->body->attachments();
Similarly to the Content class, the RichText model will implement the __toString
magic method and render the HTML content (for the end user) by casting it to a string, which in blade can be done like this:
{!! $post->body !!}
Note: since the HTML output is NOT escaped, make sure you sanitize it before rendering. See the sanitization section for more about this.
The HasRichText
trait will also add an scope which you can use to eager load the rich text fields (remember, each field will have its own relationship), which you can use like so:
// Loads all rich text fields (1 query for each field, since each has its own relationship) Post::withRichText()->get(); // Loads only a specific field: Post::withRichText('body')->get(); // Loads some specific fields (but not all): Post::withRichText(['body', 'notes'])->get();
The database structure for this example would be something like this:
posts
id (primary key)
created_at (timestamp)
updated_at (timestamp)
rich_texts
id (primary key)
field (string)
body (long text)
record_type (string)
record_id (unsigned big int)
created_at (timestamp)
updated_at (timestamp)
We store a back-reference to the field name in the rich_texts
table because a model may have multiple rich text fields, so that is used in the dynamic relationship the HasRichText
creates for you. There's also a unique constraint on this table, which prevents having multiple entries for the same model/field pair.
Rendering the rich text content back to the Trix editor is a bit differently than rendering for the end users, so you may do that using the toTrixHtml
method on the field, like so:
<x-trix-input id="post_body" name="body" value="{!! $post->body->toTrixHtml() !!}" />
Next, go to the attachments section to read more about attachables.
Encrypted Rich Text Attributes
If you want to encrypt the HTML content at-rest, you may specify the encrypted
option to true
in the richTextAttributes
property:
use Tonysm\RichTextLaravel\Models\Traits\HasRichText; class Post extends Model { use HasRichText; protected $guarded = []; protected $richTextAttributes = [ 'body' => ['encrypted' => true], // This will be encrypted... 'notes', // Not encrypted... ]; }
This uses Laravel's Encryption feature. By default, we'll encrypt using Laravel's Crypt::encryptString()
and decrypt with Crypt::decryptString()
. If you're coming from version 2 of the Rich Text Laravel package, which would default to Crypt::encrypt()
and Crypt::decrypt()
, you must migrate your data manually (see instructions in the 2.2.0 release). This is the recommended way to upgrade to 3.x.
With that being said, you may configure how the package handles encryption however you want to by calling the RichTextLaravel::encryptUsing()
method on your AppServiceProvider::boot
method. This method takes an encryption and decryption handler. The handler will receive the value, the model and key (field) that is being encrypted, like so:
namespace App\Providers; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\ServiceProvider; use Tonysm\RichTextLaravel\RichTextLaravel; class AppServiceProvider extends ServiceProvider { // ... public function boot(): void { RichTextLaravel::encryptUsing( encryption: fn ($value, $model, $key) => Crypt::encrypt($value), decryption: fn ($value, $model, $key) => Crypt::decrypt($value), ); } }
Again, it's recommended that you migrate your existing encrypted data and use the default encryption handler (see instructions here).
Key Rotation
Laravel's Encryption component relies on the APP_KEY
master key. If you need to rotate this key, you'll need to manually re-encrypt your encrypted Rich Text Attributes using the new key.
Additionally, the stored content attachments rely on the Globalid Laravel package. That package generates a derived key based on your APP_KEY
. When rotating the APP_KEY
, you'll also need to update all stored content attachments's sgid
attributes.
The AsRichTextContent Trait
In case you don't want to use the recommended structure (either because you have strong opinions here or you want to rule your own database structure), you may skip the entire recommended database structure and use the AsRichTextContent
custom cast on your rich text content field. For instance, if you're storing the body
field on the posts
table, you may do it like so:
use Tonysm\RichTextLaravel\Casts\AsRichTextContent; class Post extends Model { protected $casts = [ 'body' => AsRichTextContent::class, ]; }
Then the custom cast will parse the HTML content and minify it for storage. Essentially, it will convert this content submitted by Trix which has only an image attachment:
$post->update([ 'content' => <<<HTML <h1>Hello World</h1> <figure data-trix-attachment='{ "url": "http://example.com/blue.jpg", "width": 300, "height": 150, "contentType": "image/jpeg", "caption": "Something cool", "filename":"blue.png", "filesize":1168 }'> <img src="http://example.com/blue.jpg" width="300" height="150" /> <caption> Something cool </caption> </figure> HTML, ])
To this minified version:
<h1>Hello World</h1> <rich-text-attachment content-type="image/jpeg" filename="blue.png" filesize="1168" height="300" href="http://example.com/blue.jpg" url="http://example.com/blue.jpg" width="300" caption="testing this caption" presentation="gallery"></rich-text-attachment>
And when it renders it again, it will re-render the remote image again inside the rich-text-attachment
tag. You can render the content for viewing by simply echoing out the output, something like this:
{!! $post->content !!}
Note: since the HTML output is NOT escaped, make sure you sanitize it before rendering. See the sanitization section for more about this.
When feeding the Trix editor again, you need to do it differently:
<x-trix-input id="post_body" name="body" value="{!! $post->body->toTrixHtml() !!}" />
Rendering for the editor is a bit different, so it has to be like that.
Image Upload
Trix shows the attachment button, but it doesn't work out-of-the-box, we must implement that behavior in our applications.
A basic version of attachments uploading would look something like this:
- Listen to the
trix-attachment-add
event on the Trix element (or any parent element, as it bubbles up); - Implement the upload request. On this event, you get access to the Trix attachment instance, so you may update the progress on it if you want to, but this is not required;
- Once the upload is done, you must return the attachmentURL from upload endpoint, which you can use to set
url
andhref
attributes on the attachment itself. That's it.
The package contains a demo application with basic image uploading functionality implemented in the Workbench application. Here's some relevant links:
- The Stimulus controller that manages uploading (you should be able to map what's going on there to any JavaScript framework you'd like) can be found at resources/views/components/app-layout.blade.php, look for the "rich-text-uploader" Stimulus controller;
- The upload route can be found at routes/web.php, look for the
POST /attachments
route; - The Trix Input Blade component at resources/components/trix-input.blade.php. This is copy of the component that ships with the package with some tweaks.
However, you're not limited to this basic attachment handling in Trix. A more advanced attachment behavior could create its own backend model, then set the sgid
attribute on the attachment, which would let you have full control over the rendered HTML when the document renders outside the Trix editor.
Content Attachments
With Trix we can have content Attachments. In order to cover this, let's build a users mentions feature on top of Trix. There's a good Rails Conf talk building out this entire feature but with Rails. The workflow is pretty much the same in Laravel.
To turn any model into an Attachable, you must implement the AttachableContract
. You may use the Attachable
trait to provide some basic Attachable functionality (it implements most of the basic handling of attachables), except for the richTextRender(array $options): string
method, which you must implement. This method is used to figure out how to render the content attachment both inside and outside of Trix.
The $options
array passed to the richTextRender
is there in case you're rendering multiple models inside a gallery, so you would get a in_gallery
boolean field (optional) in that case, which is not the case for this user mentions example, so we can ignore it.
You may use Blade to render an HTML partial for the attachable. For a reference, the Workbench application ships with a User Mentions feature, which may be used as an example of content attachments. Here's some relevant links:
- The User model which implements the
AttachmentContract
can be found at User Model; - The model uses a custom Trait called
Mentionee
which uses theAttachable
trait under the hood, so take a look at the Mentionee Trait trait; - In the frontend, we're using Zurb's Tribute lib to detect mentions whenever the user types the
@
symbol in Trix. The Simulus controller that sets it up can be found at resources/views/components/app-layout.blade.php. Look for the "rich-text-mentions" controller. This is the same implement covered in the RailsConf talk mentioned earlier, so check that out if you need some help understanding what's going on. There are two Trix components in the workbench app, one used for posts and comments which may be found at resources/views/components/trix-input.blade.php and one for the Chat composer, which may be found at resources/views/chat/partials/trix-input.blade.php. In both components you will find adata-action
entry listening for thetribute-replaced
event, that's the event Tribute will dispatch for us to create the Trix attachment, providing us the selected option the user has picked from the dropdown; - The mentioner class will look for mentions in the
GET /mentions?search=
route, which you may find at routes/web.php. Note that we're turning thesgid
and thecontent
field, those are used for the Trix attachment. Thename
field is also returning, which is used by Tribute itself to compose the mentions feature. - The Blade view that will render the user attachment can be found at resources/views/mentions/partials/user.blade.php
You can later retrieve all attachments from that rich text content. See The Content Object section for more.
The Content Object
You may want to retrieve all the attachables in that rich text content at a later point and do something fancy with it, say actually storing the User's mentions associated with the Post model, for example. Or you can fetch all the links inside that rich text content and do something with it.
Getting Attachments
You may retrieve all the attachments of a rich content field using the attachments()
method both in the RichText model instance or the Content instance:
$post->body->attachments()
This will return a collection of all the attachments, anything that is an attachable, really, so images and users, for instance - if you want only attachments of a specific attachable you can use the filter method on the collection, like so:
// Getting only attachments of users inside the rich text content. $post->body->attachments() ->filter(fn (Attachment $attachment) => $attachment->attachable instanceof User) ->map(fn (Attachment $attachment) => $attachment->attachable) ->unique();
Getting Links
To extract links from the rich text content you may call the links()
method, like so:
$post->body->links()
Getting Attachment Galleries
Trix has a concept of galleries, you may want to retrieve all the galleries:
$post->body->attachmentGalleries()
This should return a collection of all the image gallery DOMElement
s.
Getting Gallery Attachments
You may also want to get only the attachments inside of image galleries. You can achieve that like this:
$post->body->galleryAttachments()
Which should return a collection with all the attachments of the images inside galleries (all of them). You can then retrieve just the RemoteImage
attachable instances like so:
$post->body->galleryAttachments() ->map(fn (Attachment $attachment) => $attachment->attachable)
Custom Content Attachments Without SGIDs
You may want to attach resources that don't need to be stored in the database. One example of this is perhaps storing the OpenGraph Embed of links in a chat message. You probably don't want to store each OpenGraph Embed as its own database record. For cases like this, where the integraty of the data isn't necessarily key, you may register a custom attachment resolver:
use App\Models\Opengraph\OpengraphEmbed; use Illuminate\Support\ServiceProvider; use Tonysm\RichTextLaravel\RichTextLaravel; class AppServiceProvider extends ServiceProvider { public function boot() { RichTextLaravel::withCustomAttachables(function (DOMElement $node) { if ($attachable = OpengraphEmbed::fromNode($node)) { return $attachable; } }); } }
This resolver must either return an instance of an AttachableContract
implementation or null
if the node doesn't match your attachment. In this case of an OpengraphEmbed
, this would look something like this:
namespace App\Models\Opengraph; use DOMElement; use Tonysm\RichTextLaravel\Attachables\AttachableContract; class OpengraphEmbed implements AttachableContract { const CONTENT_TYPE = 'application/vnd.rich-text-laravel.opengraph-embed'; public static function fromNode(DOMElement $node): ?OpengraphEmbed { if ($node->hasAttribute('content-type') && $node->getAttribute('content-type') === static::CONTENT_TYPE) { return new OpengraphEmbed(...static::attributesFromNode($node)); } return null; } // ... }
You can see a full working implementation of this OpenGraph example in the Chat Workbench demo (or in this PR).
Plain Text Rendering
Trix content can be converted to anything. This essentially means HTML > something
. The package ships with a HTML > Plain Text
implementation, so you can convert any Trix content to plain text by calling the toPlainText()
method on it:
$post->body->toPlainText()
As an example, this rich text content:
<h1>Very Important Message<h1> <p>This is an important message, with the following items:</p> <ol> <li>first item</li> <li>second item</li> </ol> <p>And here's an image:</p> <rich-text-attachment content-type="image/jpeg" filename="blue.png" filesize="1168" height="300" href="http://example.com/blue.jpg" url="http://example.com/blue.jpg" width="300" caption="The caption of the image" presentation="gallery"></rich-text-attachment> <br><br> <p>With a famous quote</p> <blockquote>Lorem Ipsum Dolor - Lorense Ipsus</blockquote> <p>Cheers,</p>
Will be converted to:
Very Important Message
This is an important message, with the following items:
1. first item
1. second item
And here's an image:
[The caption of the image]
With a famous quote
“Lorem Ipsum Dolor - Lorense Ipsus”
Cheers,
If you're attaching models, you can implement the richTextAsPlainText(?string $caption = null): string
method on it, where you should return the plain text representation of that attachable. If the method is not implemented on the attachable and no caption is stored in the Trix attachment, that attachment won't be present in the Plain Text version of the content.
Sanitization
Since we're rendering user-generated HTML, you must sanitize it to avoid any security issues. Even though we control the input element, malicious users may tamper with HTML in the browser and swap it for something else that allows them to inject their own HTML.
We recommend using Symfony's HTML Sanitizer. The Workbench application in this repository ships with a sample implementation. Here's some relevant info:
- You MUST ALWAYS escape both the HTML and plain text version of the HTML generated by the package. Never trust user-generated content.
- One example of escaped content is in the resources/views/posts/show.blade.php. Notice that the Rich Text Attributes are being passed to the
clean()
function; - The
clean()
function creates the Sanitizer (see the factory), which is a thin abstraction on top of Symfony's HTML Sanitizer (see the Sanitizer); - In all examples of the Workbench app we're only sanitizing the content on render. You may also consider sanitizing it after validation, even before passing it down to the model.
Attention: I'm not an expert in HTML content sanitization, so take this with an extra grain of salt and, please, consult someone more with more security experience on this if you can.
SGID
When storing references of custom attachments, the package uses another package called GlobalID Laravel. We store a Signed Global ID, which means users cannot simply change the sgids at-rest. They would need to generate another valid signature using the APP_KEY
, which is secret.
In case you want to rotate your key, you would need to loop-through all the rich text content, take all attachables with an sgid
attribute, assign a new value to it with the new signature using the new secret, and store the content with that new value.
Livewire
If you want to use Livewire with Trix and Rich Text Laravel, the best way to integrate would be using Livewire's @entangle()
feature. The Workbench app ships with an example app. Some interesting points:
- There's a custom components/trix-input-livewire.blade.php just to show how to use it with Livewire;
- As you can see, it relies on entangle. This is the recommended way;
- See the
Livewire\Posts
component. When the user clicks on "edit", it sets the currently editing Post into state and fills thePostForm
with the data from the Post model, including the Trix HTML;
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.