├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── art └── rich-text-laravel-logo.svg ├── composer.json ├── config └── rich-text-laravel.php ├── database └── migrations │ └── create_rich_texts_table.php.stub ├── rector.php ├── resources └── views │ ├── .gitkeep │ ├── attachables │ ├── _content.blade.php │ ├── _image_gallery.blade.php │ ├── _missing_attachable.blade.php │ ├── _remote_file.blade.php │ └── _remote_image.blade.php │ ├── attachment_galleries │ └── _attachment_gallery.blade.php │ ├── components │ └── styles.blade.php │ └── content.blade.php ├── src ├── Actions │ ├── FragmentByCanonicalizingAttachmentGalleries.php │ └── FragmentByCanonicalizingAttachments.php ├── AttachableFactory.php ├── Attachables │ ├── Attachable.php │ ├── AttachableContract.php │ ├── ContentAttachment.php │ ├── MissingAttachable.php │ ├── RemoteFile.php │ └── RemoteImage.php ├── Attachment.php ├── AttachmentGallery.php ├── Attachments │ ├── Minification.php │ └── TrixConvertion.php ├── Casts │ ├── AsEncryptedRichTextContent.php │ ├── AsRichTextContent.php │ └── ForwardsAttributeToRelationship.php ├── Commands │ └── InstallCommand.php ├── Content.php ├── Exceptions │ └── RichTextException.php ├── Fragment.php ├── HtmlConversion.php ├── Models │ ├── EncryptedRichText.php │ ├── RichText.php │ └── Traits │ │ └── HasRichText.php ├── PlainTextConversion.php ├── RichTextLaravel.php ├── RichTextLaravelServiceProvider.php ├── Serialization.php ├── TrixAttachment.php └── View │ └── Components │ └── TrixStyles.php └── stubs └── resources ├── js └── trix.js └── views └── components └── trix-input.blade.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `rich-text-laravel` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) tonysm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo Rich Text Laravel

2 | 3 |

4 | 5 | 6 | 7 | 8 | Total Downloads 9 | 10 | 11 | License 12 | 13 |

14 | 15 | Integrates the [Trix Editor](https://trix-editor.org/) with Laravel. Inspired by the Action Text gem from Rails. 16 | 17 | ## Installation 18 | 19 | You can install the package via composer: 20 | 21 | ```bash 22 | composer require tonysm/rich-text-laravel 23 | ``` 24 | 25 | Then, you may install it running: 26 | 27 | ```bash 28 | php artisan richtext:install 29 | ``` 30 | 31 | Next, you may run the migration: 32 | 33 | ```bash 34 | php artisan migrate 35 | ``` 36 | 37 | Ensure the styles Blade component were added to your layouts: 38 | 39 | ```blade 40 | 41 | ``` 42 | 43 | Alternatively, if you're using Breeze (or TailwindCSS), you may prefer the tweaked theme: 44 | 45 | ```blade 46 | 47 | ``` 48 | 49 | Finally, you may now use the published input Blade component on your forms like so: 50 | 51 | ```blade 52 | 53 | ``` 54 | 55 | That's it! 56 | 57 | ## Overview 58 | 59 | 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. 60 | 61 | 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. 62 | 63 | There are two ways of using the package: 64 | 65 | 1. With the recommended database structure where all rich text content will be stored outside of the model that has rich text content (recommended); and 66 | 1. Only using the `AsRichTextContent` trait to cast a rich text content field on any model, on any table you want. 67 | 68 | Below, we cover each usage way. It's recommended that you at least read the [Trix documentation](https://github.com/basecamp/trix) at some point to get an overview of the client-side of it. 69 | 70 | ### The RichText Model 71 | 72 | 73 | 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. 74 | 75 | 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: 76 | 77 | ```php 78 | use Tonysm\RichTextLaravel\Models\Traits\HasRichText; 79 | 80 | class Post extends Model 81 | { 82 | use HasRichText; 83 | 84 | protected $guarded = []; 85 | 86 | protected $richTextAttributes = [ 87 | 'body', 88 | 'notes', 89 | ]; 90 | } 91 | ``` 92 | 93 | This trait will create [dynamic relationships](https://laravel.com/docs/8.x/eloquent-relationships#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. 94 | 95 | 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: 96 | 97 | ```php 98 | $post = Post::create(['body' => $body, 'notes' => $notes]); 99 | ``` 100 | 101 | And you can interact with the rich text fields just like you would with any regular field on the Post model: 102 | 103 | ```php 104 | $post->body->render(); 105 | ``` 106 | 107 | 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`](./src/Content.php) 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: 108 | 109 | ```php 110 | $post->body->body->attachments(); 111 | ``` 112 | 113 | 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: 114 | 115 | ```php 116 | $post->body->attachments(); 117 | ``` 118 | 119 | 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: 120 | 121 | ```blade 122 | {!! $post->body !!} 123 | ``` 124 | 125 | *Note*: since the HTML output is NOT escaped, make sure you sanitize it before rendering. See the [sanitization](#sanitization) section for more about this. 126 | 127 | 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: 128 | 129 | ```php 130 | // Loads all rich text fields (1 query for each field, since each has its own relationship) 131 | Post::withRichText()->get(); 132 | 133 | // Loads only a specific field: 134 | Post::withRichText('body')->get(); 135 | 136 | // Loads some specific fields (but not all): 137 | Post::withRichText(['body', 'notes'])->get(); 138 | ``` 139 | 140 | The database structure for this example would be something like this: 141 | 142 | ``` 143 | posts 144 | id (primary key) 145 | created_at (timestamp) 146 | updated_at (timestamp) 147 | 148 | rich_texts 149 | id (primary key) 150 | field (string) 151 | body (long text) 152 | record_type (string) 153 | record_id (unsigned big int) 154 | created_at (timestamp) 155 | updated_at (timestamp) 156 | ``` 157 | 158 | | 💡 If you use UUIDs, you may modify the migration that creates the `rich_texts` table to use `uuidMorphs` instead of `morphs`. However, that means all your model with Rich Text content must also use UUIDs. | 159 | |------------------------| 160 | 161 | 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. 162 | 163 | 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: 164 | 165 | ```blade 166 | 167 | ``` 168 | 169 | Next, go to the [attachments](#attachments) section to read more about attachables. 170 | 171 | ### Encrypted Rich Text Attributes 172 | 173 | If you want to encrypt the HTML content at-rest, you may specify the `encrypted` option to `true` in the `richTextAttributes` property: 174 | 175 | ```php 176 | use Tonysm\RichTextLaravel\Models\Traits\HasRichText; 177 | 178 | class Post extends Model 179 | { 180 | use HasRichText; 181 | 182 | protected $guarded = []; 183 | 184 | protected $richTextAttributes = [ 185 | 'body' => ['encrypted' => true], // This will be encrypted... 186 | 'notes', // Not encrypted... 187 | ]; 188 | } 189 | ``` 190 | 191 | This uses [Laravel's Encryption](https://laravel.com/docs/encryption#introduction) 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](https://github.com/tonysm/rich-text-laravel/releases/tag/2.2.0) release). This is the recommended way to upgrade to 3.x. 192 | 193 | 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: 194 | 195 | ```php 196 | namespace App\Providers; 197 | 198 | use Illuminate\Support\Facades\Crypt; 199 | use Illuminate\Support\ServiceProvider; 200 | use Tonysm\RichTextLaravel\RichTextLaravel; 201 | 202 | class AppServiceProvider extends ServiceProvider 203 | { 204 | // ... 205 | public function boot(): void 206 | { 207 | RichTextLaravel::encryptUsing( 208 | encryption: fn ($value, $model, $key) => Crypt::encrypt($value), 209 | decryption: fn ($value, $model, $key) => Crypt::decrypt($value), 210 | ); 211 | } 212 | } 213 | ``` 214 | 215 | Again, it's recommended that you migrate your existing encrypted data and use the default encryption handler (see instructions [here](https://github.com/tonysm/rich-text-laravel/releases/tag/2.2.0)). 216 | 217 | #### Key Rotation 218 | 219 | 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. 220 | 221 | Additionally, the stored content attachments rely on the [Globalid Laravel](https://github.com/tonysm/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. 222 | 223 | ### The AsRichTextContent Trait 224 | 225 | 226 | 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: 227 | 228 | ```php 229 | use Tonysm\RichTextLaravel\Casts\AsRichTextContent; 230 | 231 | class Post extends Model 232 | { 233 | protected $casts = [ 234 | 'body' => AsRichTextContent::class, 235 | ]; 236 | } 237 | ``` 238 | 239 | 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: 240 | 241 | ```php 242 | $post->update([ 243 | 'content' => <<Hello World 245 |
254 | 255 | 256 | Something cool 257 | 258 |
259 | HTML, 260 | ]) 261 | ``` 262 | 263 | To this minified version: 264 | 265 | ```html 266 |

Hello World

267 | 268 | ``` 269 | 270 | 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: 271 | 272 | ```blade 273 | {!! $post->content !!} 274 | ``` 275 | 276 | *Note*: since the HTML output is NOT escaped, make sure you sanitize it before rendering. See the [sanitization](#sanitization) section for more about this. 277 | 278 | When feeding the Trix editor again, you need to do it differently: 279 | 280 | ```blade 281 | 282 | ``` 283 | 284 | Rendering for the editor is a bit different, so it has to be like that. 285 | 286 | ### Image Upload 287 | 288 | 289 | Trix shows the attachment button, but it doesn't work out-of-the-box, we must implement that behavior in our applications. 290 | 291 | A basic version of attachments uploading would look something like this: 292 | 293 | - Listen to the `trix-attachment-add` event on the Trix element (or any parent element, as it bubbles up); 294 | - 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; 295 | - Once the upload is done, you must return the attachmentURL from upload endpoint, which you can use to set `url` and `href` attributes on the attachment itself. That's it. 296 | 297 | The package contains a demo application with basic image uploading functionality implemented in the Workbench application. Here's some relevant links: 298 | 299 | - 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](workbench/resources/views/components/app-layout.blade.php), look for the "rich-text-uploader" Stimulus controller; 300 | - The upload route can be found at [routes/web.php](workbench/routes/web.php), look for the `POST /attachments` route; 301 | - The Trix Input Blade component at [resources/components/trix-input.blade.php](workbench/resources/views/components/trix-input.blade.php). This is copy of the component that ships with the package with some tweaks. 302 | 303 | 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. 304 | 305 | ### Content Attachments 306 | 307 | 308 | With Trix we can have [content Attachments](https://github.com/basecamp/trix#inserting-a-content-attachment). In order to cover this, let's build a users mentions feature on top of Trix. There's a good [Rails Conf talk](https://youtu.be/2iGBuLQ3S0c?t=1556) building out this entire feature but with Rails. The workflow is pretty much the same in Laravel. 309 | 310 | 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. 311 | 312 | 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. 313 | 314 | 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: 315 | 316 | - The User model which implements the `AttachmentContract` can be found at [User Model](workbench/app/Models/User.php); 317 | - The model uses a custom Trait called `Mentionee` which uses the `Attachable` trait under the hood, so take a look at the [Mentionee Trait](workbench/app/Models/User/Mentionee.php) trait; 318 | - In the frontend, we're using [Zurb's Tribute](https://github.com/zurb/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](workbench/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](workbench/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](workbench/resources/views/chat/partials/trix-input.blade.php). In both components you will find a `data-action` entry listening for the `tribute-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; 319 | - The mentioner class will look for mentions in the `GET /mentions?search=` route, which you may find at [routes/web.php](workbench/routes/web.php). Note that we're turning the `sgid` and the `content` field, those are used for the Trix attachment. The `name` field is also returning, which is used by Tribute itself to compose the mentions feature. 320 | - The Blade view that will render the user attachment can be found at [resources/views/mentions/partials/user.blade.php](workbench/resources/views/mentions/partials/user.blade.php) 321 | 322 | You can later retrieve all attachments from that rich text content. See [The Content Object](#content-object) section for more. 323 | 324 | ### The Content Object 325 | 326 | 327 | 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. 328 | 329 | #### Getting Attachments 330 | 331 | 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: 332 | 333 | ```php 334 | $post->body->attachments() 335 | ``` 336 | 337 | 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: 338 | 339 | ```php 340 | // Getting only attachments of users inside the rich text content. 341 | $post->body->attachments() 342 | ->filter(fn (Attachment $attachment) => $attachment->attachable instanceof User) 343 | ->map(fn (Attachment $attachment) => $attachment->attachable) 344 | ->unique(); 345 | ``` 346 | 347 | #### Getting Links 348 | 349 | To extract links from the rich text content you may call the `links()` method, like so: 350 | 351 | ```php 352 | $post->body->links() 353 | ``` 354 | 355 | #### Getting Attachment Galleries 356 | 357 | Trix has a concept of galleries, you may want to retrieve all the galleries: 358 | 359 | ```php 360 | $post->body->attachmentGalleries() 361 | ``` 362 | 363 | This should return a collection of all the image gallery `DOMElement`s. 364 | 365 | #### Getting Gallery Attachments 366 | 367 | You may also want to get only the _attachments_ inside of image galleries. You can achieve that like this: 368 | 369 | ```php 370 | $post->body->galleryAttachments() 371 | ``` 372 | 373 | 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: 374 | 375 | ```php 376 | $post->body->galleryAttachments() 377 | ->map(fn (Attachment $attachment) => $attachment->attachable) 378 | ``` 379 | 380 | #### Custom Content Attachments Without SGIDs 381 | 382 | 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: 383 | 384 | ```php 385 | use App\Models\Opengraph\OpengraphEmbed; 386 | use Illuminate\Support\ServiceProvider; 387 | use Tonysm\RichTextLaravel\RichTextLaravel; 388 | 389 | class AppServiceProvider extends ServiceProvider 390 | { 391 | public function boot() 392 | { 393 | RichTextLaravel::withCustomAttachables(function (DOMElement $node) { 394 | if ($attachable = OpengraphEmbed::fromNode($node)) { 395 | return $attachable; 396 | } 397 | }); 398 | } 399 | } 400 | ``` 401 | 402 | 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: 403 | 404 | ```php 405 | namespace App\Models\Opengraph; 406 | 407 | use DOMElement; 408 | use Tonysm\RichTextLaravel\Attachables\AttachableContract; 409 | 410 | class OpengraphEmbed implements AttachableContract 411 | { 412 | const CONTENT_TYPE = 'application/vnd.rich-text-laravel.opengraph-embed'; 413 | 414 | public static function fromNode(DOMElement $node): ?OpengraphEmbed 415 | { 416 | if ($node->hasAttribute('content-type') && $node->getAttribute('content-type') === static::CONTENT_TYPE) { 417 | return new OpengraphEmbed(...static::attributesFromNode($node)); 418 | } 419 | 420 | return null; 421 | } 422 | 423 | // ... 424 | } 425 | ``` 426 | 427 | You can see a full working implementation of this OpenGraph example in the Chat Workbench demo (or in [this PR](https://github.com/tonysm/rich-text-laravel/pull/56)). 428 | 429 | ### Plain Text Rendering 430 | 431 | 432 | 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: 433 | 434 | ```php 435 | $post->body->toPlainText() 436 | ``` 437 | 438 | As an example, this rich text content: 439 | 440 | ```html 441 |

Very Important Message

442 |

This is an important message, with the following items:

443 |
    444 |
  1. first item
  2. 445 |
  3. second item
  4. 446 |
447 |

And here's an image:

448 | 449 |

450 |

With a famous quote

451 |
Lorem Ipsum Dolor - Lorense Ipsus
452 |

Cheers,

453 | ``` 454 | 455 | Will be converted to: 456 | 457 | ```plaintext 458 | Very Important Message 459 | 460 | This is an important message, with the following items: 461 | 462 | 1. first item 463 | 1. second item 464 | 465 | And here's an image: 466 | 467 | [The caption of the image] 468 | 469 | With a famous quote 470 | 471 | “Lorem Ipsum Dolor - Lorense Ipsus” 472 | 473 | Cheers, 474 | ``` 475 | 476 | 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. 477 | 478 | | 💡 The plain text output representation is not HTML-safe. You must escape the plain text version generated. | 479 | |------------------------| 480 | 481 | ### Sanitization 482 | 483 | 484 | 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. 485 | 486 | We recommend using [Symfony's HTML Sanitizer](https://symfony.com/doc/current/html_sanitizer.html). The Workbench application in this repository ships with a sample implementation. Here's some relevant info: 487 | 488 | - You **MUST ALWAYS** escape both the HTML and plain text version of the HTML generated by the package. Never trust user-generated content. 489 | - One example of escaped content is in the [resources/views/posts/show.blade.php](workbench/resources/views/posts/show.blade.php). Notice that the Rich Text Attributes are being passed to the `clean()` function; 490 | - The [`clean()` function](workbench/helpers.php) creates the Sanitizer (see the [factory](workbench/app/Html/SanitizerFactory.php)), which is a thin abstraction on top of Symfony's HTML Sanitizer (see the [Sanitizer](workbench/app/Html/Sanitizer.php)); 491 | - 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. 492 | 493 | **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. 494 | 495 | ### SGID 496 | 497 | When storing references of custom attachments, the package uses another package called [GlobalID Laravel](https://github.com/tonysm/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. 498 | 499 | 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. 500 | 501 | ### Livewire 502 | 503 | 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: 504 | 505 | - There's a custom [components/trix-input-livewire.blade.php](workbench/resources/views/components/trix-input-livewire.blade.php) just to show how to use it with Livewire; 506 | - As you can see, it relies on entangle. This is the recommended way; 507 | - See the [`Livewire\Posts`](workbench/app/Livewire/Posts.php) component. When the user clicks on "edit", it sets the currently editing Post into state and fills the `PostForm` with the data from the Post model, including the Trix HTML; 508 | 509 | ## Testing 510 | 511 | ```bash 512 | composer test 513 | ``` 514 | 515 | ## Changelog 516 | 517 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 518 | 519 | ## Contributing 520 | 521 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 522 | 523 | ## Security Vulnerabilities 524 | 525 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 526 | 527 | ## Credits 528 | 529 | - [Tony Messias](https://github.com/tonysm) 530 | - [All Contributors](../../contributors) 531 | 532 | ## License 533 | 534 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 535 | -------------------------------------------------------------------------------- /art/rich-text-laravel-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 34 | 38 | 46 | 54 | 58 | 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tonysm/rich-text-laravel", 3 | "description": "Integrates Trix content with Laravel", 4 | "keywords": [ 5 | "laravel", 6 | "rich-text-laravel" 7 | ], 8 | "homepage": "https://github.com/tonysm/rich-text-laravel", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Tony Messias", 13 | "email": "tonysm@hey.com", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.2", 19 | "illuminate/contracts": "^10.0|^11.0|^12.0", 20 | "spatie/laravel-package-tools": "^1.9.2", 21 | "tonysm/globalid-laravel": "^1.1" 22 | }, 23 | "require-dev": { 24 | "laravel/pint": "^1.10", 25 | "livewire/livewire": "^3.4", 26 | "nunomaduro/collision": "^6.0|^8.0", 27 | "orchestra/testbench": "^8.21|^9.0|^10.0", 28 | "orchestra/workbench": "^8.0|^9.0|^10.0", 29 | "phpunit/phpunit": "^10.5|^11.5.3", 30 | "symfony/html-sanitizer": "^7.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Tonysm\\RichTextLaravel\\": "src", 35 | "Tonysm\\RichTextLaravel\\Database\\Factories\\": "database/factories" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Tonysm\\RichTextLaravel\\Tests\\": "tests", 41 | "Workbench\\App\\": "workbench/app/", 42 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 43 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 44 | }, 45 | "files": [ 46 | "workbench/helpers.php" 47 | ] 48 | }, 49 | "scripts": { 50 | "psalm": "vendor/bin/psalm", 51 | "test": "./vendor/bin/testbench package:test --parallel --no-coverage", 52 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage", 53 | "post-autoload-dump": [ 54 | "@clear", 55 | "@prepare" 56 | ], 57 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 58 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 59 | "build": "@php vendor/bin/testbench workbench:build --ansi", 60 | "serve": [ 61 | "Composer\\Config::disableProcessTimeout", 62 | "@build", 63 | "@php vendor/bin/testbench serve" 64 | ], 65 | "lint": [ 66 | "@php vendor/bin/pint" 67 | ] 68 | }, 69 | "config": { 70 | "sort-packages": true 71 | }, 72 | "extra": { 73 | "laravel": { 74 | "providers": [ 75 | "Tonysm\\RichTextLaravel\\RichTextLaravelServiceProvider" 76 | ] 77 | } 78 | }, 79 | "minimum-stability": "dev", 80 | "prefer-stable": true 81 | } 82 | -------------------------------------------------------------------------------- /config/rich-text-laravel.php: -------------------------------------------------------------------------------- 1 | \Tonysm\RichTextLaravel\Models\RichText::class, 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Encrypted Rich Text Model 19 | |-------------------------------------------------------------------------- 20 | | 21 | | When setting the `encrypted` option to `true` on the attribute, the package 22 | | will use this model instead of the base RichText model. 23 | | 24 | */ 25 | 'encrypted_model' => \Tonysm\RichTextLaravel\Models\EncryptedRichText::class, 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Supported Files Content-Types 30 | |-------------------------------------------------------------------------- 31 | | 32 | | When attaching non-image files to Trix, you can control here which files 33 | | you want to handle and render in the default "remote file" template 34 | | by explicitly adding your supported Content-Types to this list. 35 | | 36 | */ 37 | 'supported_files_content_types' => [ 38 | 'application/pdf', 39 | 'text/csv', 40 | 'application/msword', 41 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 42 | 'text/plain', 43 | 'application/vnd.ms-excel', 44 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 45 | ], 46 | ]; 47 | -------------------------------------------------------------------------------- /database/migrations/create_rich_texts_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 12 | $table->morphs('record'); 13 | $table->string('field'); 14 | $table->longText('body')->nullable(); 15 | $table->timestamps(); 16 | 17 | $table->unique(['field', 'record_type', 'record_id']); 18 | }); 19 | } 20 | 21 | public function down() 22 | { 23 | Schema::dropIfExists('rich_texts'); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 10 | __DIR__.'/src', 11 | __DIR__.'/tests', 12 | ]) 13 | ->withPreparedSets( 14 | deadCode: true, 15 | codeQuality: true, 16 | typeDeclarations: true, 17 | privatization: true, 18 | earlyReturn: true, 19 | ) 20 | ->withAttributesSets() 21 | ->withPhpSets() 22 | ->withPhpVersion(PhpVersion::PHP_82); 23 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonysm/rich-text-laravel/f12a80bc3683607b4ac928ef6a679a14f3522549/resources/views/.gitkeep -------------------------------------------------------------------------------- /resources/views/attachables/_content.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! trim($content->renderTrixContentAttachment($options)) !!} 3 |
4 | -------------------------------------------------------------------------------- /resources/views/attachables/_image_gallery.blade.php: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /resources/views/attachables/_missing_attachable.blade.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/views/attachables/_remote_file.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | @if ($remoteFile->caption) 4 | {{ $remoteFile->caption }} 5 | @else 6 | {{ $remoteFile->filename }} 7 | {{ $remoteFile->filesizeForHumans() }} 8 | {{ __('Download') }} 9 | @endif 10 |
11 |
12 | -------------------------------------------------------------------------------- /resources/views/attachables/_remote_image.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | @if ($remoteImage->caption) 4 |
5 | {{ $remoteImage->caption }} 6 |
7 | @endif 8 |
9 | -------------------------------------------------------------------------------- /resources/views/attachment_galleries/_attachment_gallery.blade.php: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /resources/views/components/styles.blade.php: -------------------------------------------------------------------------------- 1 | @props(['theme' => 'default']) 2 | 3 | 1025 | -------------------------------------------------------------------------------- /resources/views/content.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @if (trim($trixContent = $content->renderWithAttachments())) 3 | {!! $trixContent !!} 4 | @endif 5 |
6 | -------------------------------------------------------------------------------- /src/Actions/FragmentByCanonicalizingAttachmentGalleries.php: -------------------------------------------------------------------------------- 1 | fragmentByReplacingAttachmentGalleryNodes($content, fn (DOMElement $node): \DOMDocument => HtmlConversion::document(sprintf( 19 | '<%s>%s', 20 | AttachmentGallery::TAG_NAME, 21 | $this->getInnerHtmlOfNode($node), 22 | AttachmentGallery::TAG_NAME, 23 | )))); 24 | } 25 | 26 | public function fragmentByReplacingAttachmentGalleryNodes(string|\Tonysm\RichTextLaravel\Fragment|\DOMDocument $content, callable $callback): Fragment 27 | { 28 | return Fragment::wrap($content)->update(function (DOMDocument $source) use ($callback): \DOMDocument { 29 | $this->findAttachmentGalleryNodes($source)->each(function (DOMElement $node) use ($source, $callback): void { 30 | // The fragment is wrapped with a rich-text-root tag, so we need 31 | // to dig a bit deeper to get to the attachment gallery. 32 | 33 | $newNode = $callback($node)->firstChild->firstChild; 34 | 35 | if ($importedNode = $source->importNode($newNode, deep: true)) { 36 | $node->replaceWith($importedNode); 37 | } 38 | }); 39 | 40 | return $source; 41 | }); 42 | } 43 | 44 | public function findAttachmentGalleryNodes(string|\Tonysm\RichTextLaravel\Fragment|\DOMDocument $content): Collection 45 | { 46 | return Fragment::wrap($content) 47 | ->findAll(AttachmentGallery::selector()) 48 | ->filter(function (DOMElement $node): bool { 49 | // We are only interested in DIVs that only contain rich-text-attachment 50 | // tags. But they may contain empty texts as well, we can ignore them 51 | // when converting the gallery attachments node objects later on. 52 | 53 | foreach ($node->childNodes as $child) { 54 | if (! $this->galleryAttachmentNode($child) && ! $this->emptyTextNode($child)) { 55 | return false; 56 | } 57 | } 58 | 59 | return true; 60 | }); 61 | } 62 | 63 | private function getInnerHtmlOfNode(DOMElement $node): string 64 | { 65 | $innerContent = ''; 66 | 67 | foreach ($node->childNodes as $child) { 68 | $innerContent .= $child->ownerDocument->saveHtml($child); 69 | } 70 | 71 | return $innerContent; 72 | } 73 | 74 | private function galleryAttachmentNode(DOMNode $node): bool 75 | { 76 | return $node instanceof DOMElement 77 | && $node->hasAttribute('presentation') 78 | && $node->getAttribute('presentation') === 'gallery'; 79 | } 80 | 81 | private function emptyTextNode(DOMNode $node): bool 82 | { 83 | return $node instanceof DOMText 84 | && in_array(trim($node->textContent), ['', '0'], true); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Actions/FragmentByCanonicalizingAttachments.php: -------------------------------------------------------------------------------- 1 | parse($content)); 16 | } 17 | 18 | public function parse($content) 19 | { 20 | return $this->fragmentByMinifyingAttachments( 21 | $this->fragmentByConvertingTrixAttachments($content) 22 | ); 23 | } 24 | 25 | public static function fromAttributes(array $attributes) 26 | { 27 | return Attachment::fromAttributes($attributes); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/AttachableFactory.php: -------------------------------------------------------------------------------- 1 | hasAttribute('sgid') && $attachable = static::attachableFromSgid($node->getAttribute('sgid'))) { 20 | return $attachable; 21 | } 22 | 23 | if (($attachable = ContentAttachment::fromNode($node)) instanceof \Tonysm\RichTextLaravel\Attachables\ContentAttachment) { 24 | return $attachable; 25 | } 26 | 27 | if (($attachable = RemoteImage::fromNode($node)) instanceof \Tonysm\RichTextLaravel\Attachables\RemoteImage) { 28 | return $attachable; 29 | } 30 | 31 | if (($attachable = RemoteFile::fromNode($node)) instanceof \Tonysm\RichTextLaravel\Attachables\RemoteFile) { 32 | return $attachable; 33 | } 34 | 35 | return new Attachables\MissingAttachable; 36 | } 37 | 38 | private static function attachableFromSgid(string $sgid) 39 | { 40 | return Locator::locateSigned($sgid, [ 41 | 'for' => 'rich-text-laravel', 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Attachables/Attachable.php: -------------------------------------------------------------------------------- 1 | replace([ 42 | 'sgid' => $this->richTextSgid(), 43 | 'content_type' => $this->richTextContentType(), 44 | 'previewable' => $this->richTextPreviewable(), 45 | 'filename' => $this->richTextFilename(), 46 | 'filesize' => $this->richTextFilesize(), 47 | 'width' => $this->richTextMetadata('width'), 48 | 'height' => $this->richTextMetadata('height'), 49 | ]) 50 | ->filter() 51 | ->all(); 52 | } 53 | 54 | public function toTrixContent(): ?string 55 | { 56 | return $this->richTextRender(); 57 | } 58 | 59 | public function richTextSgid(): string 60 | { 61 | return SignedGlobalId::create($this, [ 62 | 'for' => 'rich-text-laravel', 63 | 'expires_at' => null, 64 | ])->toString(); 65 | } 66 | 67 | public function equalsToAttachable(AttachableContract $attachable): bool 68 | { 69 | if ($this instanceof Model && $attachable instanceof Model) { 70 | return $this->is($attachable); 71 | } 72 | 73 | return $this->richTextRender() == $attachable->richTextRender(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Attachables/AttachableContract.php: -------------------------------------------------------------------------------- 1 | hasAttribute('content-type') || ! $node->hasAttribute('content')) { 17 | return null; 18 | } 19 | 20 | $contentType = $node->getAttribute('content-type'); 21 | $content = trim($node->getAttribute('content')); 22 | 23 | if (str_contains($contentType, 'html') && ($content !== '' && $content !== '0')) { 24 | return new static($contentType, $content); 25 | } 26 | } 27 | 28 | public function __construct( 29 | public string $contentType, 30 | public string $content, 31 | ) {} 32 | 33 | public function toRichTextAttributes(array $attributes): array 34 | { 35 | return [ 36 | 'contentType' => $this->contentType, 37 | 'content' => $this->content, 38 | ]; 39 | } 40 | 41 | public function equalsToAttachable(AttachableContract $attachable): bool 42 | { 43 | return $attachable instanceof static 44 | && $attachable->contentType === $this->contentType 45 | && $attachable->content === $this->content; 46 | } 47 | 48 | public function richTextAsPlainText(): string 49 | { 50 | return $this->contentInstance()->fragment->source->textContent; 51 | } 52 | 53 | public function richTextRender(array $options = []): string 54 | { 55 | return view('rich-text-laravel::attachables._content', [ 56 | 'content' => $this, 57 | 'options' => $options, 58 | ])->render(); 59 | } 60 | 61 | public function renderTrixContentAttachment(array $options = []): string 62 | { 63 | return $this->renderedHtml ??= $this->contentInstance()->fragment->toHtml(); 64 | } 65 | 66 | private function contentInstance(): Content 67 | { 68 | return $this->contentInstance ??= new Content($this->content); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Attachables/MissingAttachable.php: -------------------------------------------------------------------------------- 1 | render(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Attachables/RemoteFile.php: -------------------------------------------------------------------------------- 1 | hasAttribute('url') && in_array($node->getAttribute('content-type'), config('rich-text-laravel.supported_files_content_types'))) { 23 | return new static(static::attributesFromNode($node)); 24 | } 25 | 26 | return null; 27 | } 28 | 29 | private static function attributesFromNode(DOMElement $node): array 30 | { 31 | return [ 32 | 'url' => $node->getAttribute('url'), 33 | 'content_type' => $node->getAttribute('content-type'), 34 | 'filename' => $node->getAttribute('filename'), 35 | 'filesize' => $node->getAttribute('filesize'), 36 | 'caption' => $node->hasAttribute('caption') ? $node->getAttribute('caption') : null, 37 | ]; 38 | } 39 | 40 | public function __construct(array $attributes) 41 | { 42 | $this->url = $attributes['url']; 43 | $this->contentType = $attributes['content_type']; 44 | $this->filename = $attributes['filename']; 45 | $this->filesize = $attributes['filesize']; 46 | $this->caption = $attributes['caption']; 47 | } 48 | 49 | public function richTextContentType(): string 50 | { 51 | return $this->contentType; 52 | } 53 | 54 | public function richTextMetadata(?string $key = null) 55 | { 56 | $data = [ 57 | 'contentType' => $this->contentType, 58 | 'url' => $this->url, 59 | 'filename' => $this->filename, 60 | 'filesize' => $this->filesize, 61 | 'caption' => $this->caption, 62 | ]; 63 | 64 | if (! $key) { 65 | return $data; 66 | } 67 | 68 | return $data[$key] ?? null; 69 | } 70 | 71 | public function richTextSgid(): string 72 | { 73 | return ''; 74 | } 75 | 76 | public function toRichTextAttributes(array $attributes): array 77 | { 78 | return [ 79 | 'content_type' => $this->richTextContentType(), 80 | 'filename' => $this->filename, 81 | 'filesize' => $this->filesize, 82 | ]; 83 | } 84 | 85 | public function equalsToAttachable(AttachableContract $attachable): bool 86 | { 87 | return $attachable instanceof static 88 | && $attachable->richTextMetadata() == $this->richTextMetadata(); 89 | } 90 | 91 | public function richTextRender(array $options = []): string 92 | { 93 | return view('rich-text-laravel::attachables._remote_file', [ 94 | 'remoteFile' => $this, 95 | ])->render(); 96 | } 97 | 98 | public function richTextAsPlainText($caption = null): string 99 | { 100 | return __(sprintf('[%s]', ($caption ?: $this->filename) ?: 'File')); 101 | } 102 | 103 | public function extension(): string 104 | { 105 | return (string) Str::of($this->url ?: '.unkown')->afterLast('.'); 106 | } 107 | 108 | public function filesizeForHumans(): string 109 | { 110 | if ($this->filesize >= 1 << 30) { 111 | return number_format($this->filesize / (1 << 30), 2).' GB'; 112 | } 113 | 114 | if ($this->filesize >= 1 << 20) { 115 | return number_format($this->filesize / (1 << 20), 2).' MB'; 116 | } 117 | 118 | if ($this->filesize >= 1 << 10) { 119 | return number_format($this->filesize / (1 << 10), 2).' KB'; 120 | } 121 | 122 | return number_format($this->filesize).' Bytes'; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Attachables/RemoteImage.php: -------------------------------------------------------------------------------- 1 | hasAttribute('url') && str_starts_with($node->getAttribute('content-type'), 'image')) { 27 | return new static(static::attributesFromNode($node)); 28 | } 29 | 30 | return null; 31 | } 32 | 33 | private static function attributesFromNode(DOMElement $node): array 34 | { 35 | return [ 36 | 'url' => $node->getAttribute('url'), 37 | 'content_type' => $node->getAttribute('content-type'), 38 | 'width' => $node->getAttribute('width'), 39 | 'height' => $node->getAttribute('height'), 40 | 'filename' => $node->getAttribute('filename'), 41 | 'filesize' => $node->getAttribute('filesize'), 42 | 'caption' => $node->hasAttribute('caption') ? $node->getAttribute('caption') : null, 43 | ]; 44 | } 45 | 46 | public function __construct(array $attributes) 47 | { 48 | $this->url = $attributes['url']; 49 | $this->contentType = $attributes['content_type']; 50 | $this->width = $attributes['width']; 51 | $this->height = $attributes['height']; 52 | $this->filename = $attributes['filename']; 53 | $this->filesize = $attributes['filesize']; 54 | $this->caption = $attributes['caption']; 55 | } 56 | 57 | public function richTextContentType(): string 58 | { 59 | return $this->contentType; 60 | } 61 | 62 | public function richTextMetadata(?string $key = null) 63 | { 64 | $data = [ 65 | 'width' => $this->width, 66 | 'height' => $this->height, 67 | 'contentType' => $this->contentType, 68 | 'url' => $this->url, 69 | 'filename' => $this->filename, 70 | 'filesize' => $this->filesize, 71 | 'caption' => $this->caption, 72 | ]; 73 | 74 | if (! $key) { 75 | return $data; 76 | } 77 | 78 | return $data[$key] ?? null; 79 | } 80 | 81 | public function richTextSgid(): string 82 | { 83 | return ''; 84 | } 85 | 86 | public function toRichTextAttributes(array $attributes): array 87 | { 88 | return [ 89 | 'content_type' => $this->richTextContentType(), 90 | 'filename' => $this->filename, 91 | 'filesize' => $this->filesize, 92 | 'width' => $this->width, 93 | 'height' => $this->height, 94 | ]; 95 | } 96 | 97 | public function equalsToAttachable(AttachableContract $attachable): bool 98 | { 99 | return $attachable instanceof static 100 | && $attachable->richTextMetadata() == $this->richTextMetadata(); 101 | } 102 | 103 | public function richTextRender(array $options = []): string 104 | { 105 | return view('rich-text-laravel::attachables._remote_image', [ 106 | 'remoteImage' => $this, 107 | ])->render(); 108 | } 109 | 110 | public function richTextAsPlainText($caption = null): string 111 | { 112 | return sprintf('[%s]', $caption ?: 'Image'); 113 | } 114 | 115 | public function extension(): string 116 | { 117 | return (string) Str::of($this->url ?: '.unkown')->afterLast('.'); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Attachment.php: -------------------------------------------------------------------------------- 1 | toRichTextAttributes($attributes))) instanceof \DOMElement) { 38 | return new static($node, $attachable); 39 | } 40 | 41 | return null; 42 | } 43 | 44 | public static function fromNode(DOMElement $node, ?AttachableContract $attachable = null): static 45 | { 46 | return new static($node, $attachable ?: AttachableFactory::fromNode($node)); 47 | } 48 | 49 | /** 50 | * @return null|static 51 | */ 52 | public static function fromAttributes(array $attributes = [], ?AttachableContract $attachable = null): ?self 53 | { 54 | if (($node = static::nodeFromAttributes($attributes)) instanceof \DOMElement) { 55 | return static::fromNode($node, $attachable); 56 | } 57 | 58 | return null; 59 | } 60 | 61 | public static function nodeFromAttributes(array $attributes = []): ?DOMElement 62 | { 63 | $attributes = static::processAttributes($attributes); 64 | 65 | if ($attributes === []) { 66 | return null; 67 | } 68 | 69 | return HtmlConversion::createElement(static::$TAG_NAME, $attributes); 70 | } 71 | 72 | private static function processAttributes(array $attributes): array 73 | { 74 | return collect($attributes) 75 | ->mapWithKeys(function ($value, $key) { 76 | $newKey = (string) Str::of($key)->camel()->snake('-'); 77 | 78 | return [$newKey => $value]; 79 | }) 80 | ->only(static::ATTRIBUTES) 81 | ->all(); 82 | } 83 | 84 | public function __construct(public DOMElement $node, public AttachableContract $attachable) {} 85 | 86 | public function withFullAttributes(): static 87 | { 88 | return $this; 89 | } 90 | 91 | public function caption() 92 | { 93 | return $this->nodeAttributes()['caption'] ?? null; 94 | } 95 | 96 | public function toPlainText(): string 97 | { 98 | if (method_exists($this->attachable, 'richTextAsPlainText')) { 99 | return $this->attachable->richTextAsPlainText($this->caption()); 100 | } 101 | 102 | return $this->caption() ?: ''; 103 | } 104 | 105 | public function toHtml(): string 106 | { 107 | return HtmlConversion::nodeElementToHtml($this->node); 108 | } 109 | 110 | public function fullAttributes(): Collection 111 | { 112 | return $this->nodeAttributes() 113 | ->merge($this->attachableAttributes()) 114 | ->merge($this->sgidAttributes()); 115 | } 116 | 117 | private function attachableAttributes(): Collection 118 | { 119 | return $this->cachedAttachableAttributes ??= collect( 120 | $this->attachable->toRichTextAttributes([]) 121 | ); 122 | } 123 | 124 | private function sgidAttributes(): Collection 125 | { 126 | return $this->cachedSgidAttributes ??= collect([ 127 | 'sgid' => $this->nodeAttributes()->get('sgid', $this->attachableAttributes()->get('sgid')), 128 | ])->filter(); 129 | } 130 | 131 | private function nodeAttributes(): Collection 132 | { 133 | return $this->cachedAttributes ??= collect(static::ATTRIBUTES) 134 | ->mapWithKeys(function ($key) { 135 | $newKey = (string) Str::of($key)->snake(); 136 | 137 | return [$newKey => $this->node->hasAttribute($newKey) ? $this->node->getAttribute($newKey) : null]; 138 | }); 139 | } 140 | 141 | public function is(Attachment $attachment): bool 142 | { 143 | return $this->attachable->equalsToAttachable($attachment->attachable); 144 | } 145 | 146 | public function __toString(): string 147 | { 148 | return $this->toHtml(); 149 | } 150 | 151 | public function __call($method, $arguments) 152 | { 153 | return $this->forwardCallTo($this->attachable, $method, $arguments); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/AttachmentGallery.php: -------------------------------------------------------------------------------- 1 | 1]', 32 | static::TAG_NAME, 33 | static::attachmentSelector(), 34 | ); 35 | } 36 | 37 | public function __construct(public DOMElement $node) {} 38 | 39 | public function attachments(): Collection 40 | { 41 | return $this->cachedAttachments ??= $this->computeAttachments(); 42 | } 43 | 44 | public function count(): int 45 | { 46 | return $this->attachments()->count(); 47 | } 48 | 49 | public function richTextRender(): string 50 | { 51 | return view('rich-text-laravel::attachment_galleries._attachment_gallery', [ 52 | 'attachmentGallery' => $this, 53 | ])->render(); 54 | } 55 | 56 | private function computeAttachments(): Collection 57 | { 58 | $xpath = new DOMXPath($this->node->ownerDocument); 59 | $attachmentNodes = $xpath->query(static::attachmentSelector(), $this->node); 60 | $result = collect(); 61 | 62 | if ($attachmentNodes === false) { 63 | return $result; 64 | } 65 | 66 | foreach ($attachmentNodes as $node) { 67 | $result->add(Attachment::fromNode($node)->withFullAttributes()); 68 | } 69 | 70 | return $result; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Attachments/Minification.php: -------------------------------------------------------------------------------- 1 | replace(Attachment::$TAG_NAME, fn (DOMElement $node): \DOMElement => $node->cloneNode(deep: false)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Attachments/TrixConvertion.php: -------------------------------------------------------------------------------- 1 | replace(TrixAttachment::$SELECTOR, fn (DOMElement $node) => static::fromTrixAttachment(new TrixAttachment($node))); 14 | } 15 | 16 | public static function fragmentByConvertingTrixContent($content) 17 | { 18 | return Fragment::wrap($content)->replace(TrixAttachment::$SELECTOR, fn (DOMElement $node) => static::fromTrixAttachment(new TrixAttachment($node))); 19 | } 20 | 21 | public static function fromTrixAttachment(TrixAttachment $attachment) 22 | { 23 | return static::fromAttributes($attachment->attributes()); 24 | } 25 | 26 | public function toTrixAttachment($content = null): \Tonysm\RichTextLaravel\TrixAttachment 27 | { 28 | /** @psalm-suppress UndefinedThisPropertyFetch */ 29 | if (! $content && method_exists($this->attachable, 'toTrixContent')) { 30 | $content = $this->attachable->toTrixContent(); 31 | } 32 | 33 | /** @psalm-suppress UndefinedMethod */ 34 | $attributes = $this->fullAttributes()->filter()->all(); 35 | 36 | if ($content) { 37 | $attributes['content'] = $content; 38 | } 39 | 40 | return TrixAttachment::fromAttributes($attributes); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Casts/AsEncryptedRichTextContent.php: -------------------------------------------------------------------------------- 1 | firstOrNewRelationship($model, $key); 23 | $richText->fill(['body' => $value]); 24 | } 25 | 26 | return []; 27 | } 28 | 29 | /** 30 | * Cast the given value. 31 | * 32 | * @param \Illuminate\Database\Eloquent\Model $model 33 | * @param string $key 34 | * @param mixed $value 35 | * @param array $attributes 36 | * @return mixed 37 | */ 38 | public function get($model, $key, $value, $attributes) 39 | { 40 | return $this->firstOrNewRelationship($model, $key); 41 | } 42 | 43 | /** 44 | * @return \Tonysm\RichTextLaravel\Models\RichText 45 | */ 46 | public function firstOrNewRelationship(Model $model, string $field) 47 | { 48 | $relationship = $model::fieldToRichTextRelationship($field); 49 | 50 | if ($model->{$relationship}) { 51 | return $model->{$relationship}; 52 | } 53 | 54 | $richText = $model->{$relationship}()->make(['field' => $field]); 55 | $model->setRelation($relationship, $richText); 56 | 57 | return $richText; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | option('no-model')) { 27 | $this->publishMigration(); 28 | } 29 | 30 | $this->ensureTrixLibIsImported(); 31 | $this->ensureTrixFieldComponentIsCopied(); 32 | $this->updateAppLayoutFiles(); 33 | $this->updateJsDependencies(); 34 | $this->runDatabaseMigrations(); 35 | 36 | $this->newLine(); 37 | $this->components->info('Rich Text Laravel was installed successfully.'); 38 | 39 | return self::SUCCESS; 40 | } 41 | 42 | private function publishMigration(): void 43 | { 44 | FacadesProcess::forever()->run([ 45 | $this->phpBinary(), 46 | 'artisan', 47 | 'vendor:publish', 48 | '--tag', 'rich-text-laravel-migrations', 49 | '--provider', RichTextLaravelServiceProvider::class, 50 | ], fn ($_type, $output) => $this->output->write($output)); 51 | } 52 | 53 | private function updateJsDependencies(): void 54 | { 55 | if ($this->usingImportmaps()) { 56 | $this->installJsDependenciesWithImportmaps(); 57 | } else { 58 | $this->updateJsDependenciesWithNpm(); 59 | } 60 | } 61 | 62 | private function runDatabaseMigrations(): void 63 | { 64 | if (! $this->confirm('A new migration was published to your app. Do you want to run it now?', true)) { 65 | return; 66 | } 67 | 68 | if ($this->runningSail() && ! env('LARAVEL_SAIL')) { 69 | FacadesProcess::forever()->run([ 70 | './vendor/bin/sail', 71 | 'artisan', 72 | 'migrate', 73 | ], fn ($_type, $output) => $this->output->write($output)); 74 | } else { 75 | FacadesProcess::forever()->run([ 76 | $this->phpBinary(), 77 | 'artisan', 78 | 'migrate', 79 | ], fn ($_type, $output) => $this->output->write($output)); 80 | } 81 | } 82 | 83 | private function runningSail(): bool 84 | { 85 | return file_exists(base_path('docker-compose.yml')) && str_contains(file_get_contents(base_path('composer.json')), 'laravel/sail'); 86 | } 87 | 88 | private function usingImportmaps(): bool 89 | { 90 | return File::exists(base_path('routes/importmap.php')); 91 | } 92 | 93 | private function jsDependencies(): array 94 | { 95 | return [ 96 | 'trix' => '^2.0.10', 97 | ]; 98 | } 99 | 100 | private function updateJsDependenciesWithNpm(): void 101 | { 102 | static::updateNodePackages(fn ($packages): array => $this->jsDependencies() + $packages); 103 | 104 | if (file_exists(base_path('pnpm-lock.yaml'))) { 105 | $this->runCommands(['pnpm install', 'pnpm run build']); 106 | } elseif (file_exists(base_path('yarn.lock'))) { 107 | $this->runCommands(['yarn install', 'yarn run build']); 108 | } else { 109 | $this->runCommands(['npm install', 'npm run build']); 110 | } 111 | } 112 | 113 | private function runCommands(array $commands): void 114 | { 115 | $process = Process::fromShellCommandline(implode(' && ', $commands), null, null, null, null); 116 | 117 | if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { 118 | try { 119 | $process->setTty(true); 120 | } catch (RuntimeException $e) { 121 | $this->output->writeln(' WARN '.$e->getMessage().PHP_EOL); 122 | } 123 | } 124 | 125 | $process->run(function ($type, string $line): void { 126 | $this->output->write(' '.$line); 127 | }); 128 | } 129 | 130 | private function installJsDependenciesWithImportmaps(): void 131 | { 132 | FacadesProcess::forever()->run(array_merge([ 133 | $this->phpBinary(), 134 | 'artisan', 135 | 'importmap:pin', 136 | ], array_keys($this->jsDependencies())), fn ($_type, $output) => $this->output->write($output)); 137 | } 138 | 139 | private function ensureTrixLibIsImported(): void 140 | { 141 | $trixRelativeDestinationPath = 'resources/js/libs/trix.js'; 142 | 143 | $trixAbsoluteDestinationPath = base_path($trixRelativeDestinationPath); 144 | 145 | if (File::exists($trixAbsoluteDestinationPath)) { 146 | $this->components->warn("File {$trixRelativeDestinationPath} already exists."); 147 | } else { 148 | File::ensureDirectoryExists(dirname($trixAbsoluteDestinationPath), recursive: true); 149 | File::copy(__DIR__.'/../../stubs/resources/js/trix.js', $trixAbsoluteDestinationPath); 150 | } 151 | 152 | $entrypoint = Arr::first([ 153 | resource_path('js/libs/index.js'), 154 | resource_path('js/app.js'), 155 | ], fn ($file): bool => file_exists($file)); 156 | 157 | if (! $entrypoint) { 158 | return; 159 | } 160 | 161 | if (preg_match(self::JS_TRIX_LIBS_IMPORT_PATTERN, File::get($entrypoint))) { 162 | return; 163 | } 164 | 165 | File::prepend($entrypoint, str_replace('%path%', $this->usingImportmaps() ? '' : './', <<<'JS' 166 | import "%path%libs/trix"; 167 | 168 | JS)); 169 | } 170 | 171 | private function ensureTrixFieldComponentIsCopied(): void 172 | { 173 | File::ensureDirectoryExists(resource_path('views/components')); 174 | 175 | File::copy( 176 | __DIR__.'/../../stubs/resources/views/components/trix-input.blade.php', 177 | resource_path('views/components/trix-input.blade.php'), 178 | ); 179 | } 180 | 181 | private function updateAppLayoutFiles(): void 182 | { 183 | $layouts = $this->existingLayoutFiles(); 184 | 185 | if ($layouts->isEmpty()) { 186 | return; 187 | } 188 | 189 | $layouts->each(function ($file): void { 190 | $contents = File::get($file); 191 | 192 | if (str_contains($contents, ')/', '\\1 \\1\\2', $contents)); 197 | }); 198 | } 199 | 200 | private function existingLayoutFiles() 201 | { 202 | return collect(['app', 'guest']) 203 | ->map(fn ($name) => resource_path("views/layouts/{$name}.blade.php")) 204 | ->filter(fn ($file) => File::exists($file)); 205 | } 206 | 207 | /** 208 | * Update the "package.json" file. 209 | * 210 | * @param bool $dev 211 | * @return void 212 | */ 213 | protected static function updateNodePackages(callable $callback, $dev = true) 214 | { 215 | if (! file_exists(base_path('package.json'))) { 216 | return; 217 | } 218 | 219 | $configurationKey = $dev ? 'devDependencies' : 'dependencies'; 220 | 221 | $packages = json_decode(file_get_contents(base_path('package.json')), true); 222 | 223 | $packages[$configurationKey] = $callback( 224 | array_key_exists($configurationKey, $packages) ? $packages[$configurationKey] : [], 225 | $configurationKey 226 | ); 227 | 228 | ksort($packages[$configurationKey]); 229 | 230 | file_put_contents( 231 | base_path('package.json'), 232 | json_encode($packages, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).PHP_EOL 233 | ); 234 | } 235 | 236 | private function phpBinary(): string 237 | { 238 | return (new PhpExecutableFinder)->find(false) ?: 'php'; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/Content.php: -------------------------------------------------------------------------------- 1 | false]); 30 | } 31 | 32 | public static function toStorage(?string $value = null) 33 | { 34 | return static::fragmentByCanonicalizingContent($value ?: '')->toHtml(); 35 | } 36 | 37 | public static function fragmentByCanonicalizingContent(string $content) 38 | { 39 | return (new Pipeline(app())) 40 | ->send($content) 41 | ->through([ 42 | Actions\FragmentByCanonicalizingAttachments::class, 43 | Actions\FragmentByCanonicalizingAttachmentGalleries::class, 44 | ]) 45 | ->thenReturn(); 46 | } 47 | 48 | public function __construct(string|\Tonysm\RichTextLaravel\Fragment|\DOMDocument $content, array $options = []) 49 | { 50 | if ($options['canonicalize'] ?? true) { 51 | $this->fragment = static::fragmentByCanonicalizingContent($content); 52 | } else { 53 | $this->fragment = Fragment::wrap($content); 54 | } 55 | } 56 | 57 | public function links(): Collection 58 | { 59 | return $this->fragment->findAll('//a[@href]') 60 | ->map(fn (DOMElement $node): string => $node->getAttribute('href')) 61 | ->unique(); 62 | } 63 | 64 | public function attachments(): Collection 65 | { 66 | return $this->cachedAttachments ??= $this->attachmentNodes()->map(fn (DOMElement $node): \Tonysm\RichTextLaravel\Attachment => ( 67 | $this->attachmentForNode($node) 68 | )); 69 | } 70 | 71 | public function attachmentGalleries(): Collection 72 | { 73 | return $this->cachedAttachmentGalleries ??= $this->attachmentGalleryNodes()->map(fn (DOMElement $node) => ( 74 | $this->attachmentGalleryForNode($node) 75 | )); 76 | } 77 | 78 | public function attachables(): Collection 79 | { 80 | return $this->cachedAttachables ??= $this->attachmentNodes()->map(fn (DOMElement $node): \Tonysm\RichTextLaravel\Attachables\AttachableContract => ( 81 | AttachableFactory::fromNode($node) 82 | )); 83 | } 84 | 85 | public function galleryAttachments(): Collection 86 | { 87 | return $this->cachedGalleryAttachments ??= $this->attachmentGalleries()->flatMap(fn (AttachmentGallery $attachmentGallery): \Illuminate\Support\Collection => $attachmentGallery->attachments()); 88 | } 89 | 90 | public function renderAttachments(array $options, callable $callback): static 91 | { 92 | $content = $this->fragment->replace(Attachment::$SELECTOR, fn (DOMNode $node) => $callback($this->attachmentForNode($node, $options))); 93 | 94 | return new static($content, ['canonicalize' => false]); 95 | } 96 | 97 | public function toPlainText(): string 98 | { 99 | return $this->renderAttachments( 100 | ['withFullAttributes' => false], 101 | fn (Attachment $item): string => $item->toPlainText() 102 | )->fragment->toPlainText(); 103 | } 104 | 105 | public function toTrixHtml(): string 106 | { 107 | return $this->renderAttachments( 108 | [], 109 | fn (Attachment $attachment): \Tonysm\RichTextLaravel\Fragment => (HtmlConversion::fragmentForHtml($attachment->toTrixAttachment()->toHtml())) 110 | )->toHtml(); 111 | } 112 | 113 | public function toHtml(): string 114 | { 115 | return $this->renderAttachments([], fn (Attachment $attachment) => $attachment->toTrixAttachment()) 116 | ->fragment->toHtml(); 117 | } 118 | 119 | public function renderWithAttachments(): string 120 | { 121 | return $this->renderAttachments([], function (Attachment $attachment): ?\Tonysm\RichTextLaravel\Fragment { 122 | // If this is a gallery attachment, we'll render it separately. 123 | 124 | if ($this->galleryAttachments()->first(fn (Attachment $galleryAttachment): bool => $galleryAttachment->is($attachment))) { 125 | return null; 126 | } 127 | 128 | return HtmlConversion::fragmentForHtml($this->renderAttachment($attachment, [ 129 | 'in_gallery' => false, 130 | ])); 131 | })->renderAttachmentGalleries(fn (AttachmentGallery $attachmentGallery): string => ( 132 | $attachmentGallery->richTextRender() 133 | ))->fragment->toHtml(); 134 | } 135 | 136 | public function renderAttachmentGalleries(callable $renderer): static 137 | { 138 | $content = (new FragmentByCanonicalizingAttachmentGalleries)->fragmentByReplacingAttachmentGalleryNodes($this->fragment, fn (DOMElement $node): \DOMDocument => HtmlConversion::document($renderer($this->attachmentGalleryForNode($node)))); 139 | 140 | return new static($content, ['canonicalize' => false]); 141 | } 142 | 143 | public function renderAttachment(Attachment $attachment, array $locals = []): string 144 | { 145 | return $attachment->attachable->richTextRender(options: $locals); 146 | } 147 | 148 | public function render() 149 | { 150 | return view('rich-text-laravel::content', [ 151 | 'content' => $this, 152 | ])->render(); 153 | } 154 | 155 | public function raw(): string 156 | { 157 | return $this->fragment->toHtml(); 158 | } 159 | 160 | public function isEmpty(): bool 161 | { 162 | return in_array(trim($this->toHtml()), ['', '0'], true); 163 | } 164 | 165 | public function __toString(): string 166 | { 167 | return (string) $this->render(); 168 | } 169 | 170 | private function attachmentNodes(): Collection 171 | { 172 | return $this->cachedAttachmentNodes ??= $this->fragment->findAll(Attachment::$SELECTOR); 173 | } 174 | 175 | private function attachmentGalleryNodes(): Collection 176 | { 177 | return $this->cachedAttachmentGalleryNodes ??= (new FragmentByCanonicalizingAttachmentGalleries)->findAttachmentGalleryNodes($this->fragment); 178 | } 179 | 180 | private function attachmentGalleryForNode(DOMElement $node): \Tonysm\RichTextLaravel\AttachmentGallery 181 | { 182 | return AttachmentGallery::fromNode($node); 183 | } 184 | 185 | private function attachmentForNode(DOMNode $node, array $options = []): Attachment 186 | { 187 | $attachment = Attachment::fromNode($node); 188 | 189 | if ($options['withFullAttributes'] ?? false) { 190 | return $attachment->withFullAttributes(); 191 | } 192 | 193 | return $attachment; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Exceptions/RichTextException.php: -------------------------------------------------------------------------------- 1 | source); 39 | 40 | $elements = $xpath->query($selector); 41 | 42 | if ($elements === false) { 43 | return collect([]); 44 | } 45 | 46 | $result = collect([]); 47 | 48 | foreach ($elements as $element) { 49 | $result->add($element); 50 | } 51 | 52 | return $result; 53 | } 54 | 55 | public function update(?callable $callback = null): static 56 | { 57 | $callback = $callback ?: fn ($source) => $source; 58 | 59 | return new static($callback($this->source->cloneNode(deep: true))); 60 | } 61 | 62 | public function replace(string $selector, callable $callback): static 63 | { 64 | $fragment = $this->update(); 65 | 66 | $fragment->findAll($selector) 67 | ->each(function (DOMNode $node) use ($callback): void { 68 | $value = $callback($node); 69 | 70 | if ($value instanceof Fragment) { 71 | // Each fragment source is wrapped in a div, so we can ignore it when appending. 72 | $newNode = $value->source; 73 | 74 | foreach ($newNode->firstChild->childNodes as $child) { 75 | if ($importedNode = $node->ownerDocument->importNode($child, deep: true)) { 76 | $node->parentNode->insertBefore($importedNode, $node); 77 | } 78 | } 79 | 80 | $node->parentNode->removeChild($node); 81 | } elseif (is_string($value)) { 82 | $newNode = $node->ownerDocument->createTextNode($value); 83 | $node->parentNode->replaceChild($newNode, $node); 84 | } elseif ($value instanceof Attachment) { 85 | if ($importedNode = $node->ownerDocument->importNode($value->node, deep: true)) { 86 | $node->parentNode->insertBefore($importedNode, $node); 87 | } 88 | 89 | $node->parentNode->removeChild($node); 90 | } 91 | }); 92 | 93 | return $fragment; 94 | } 95 | 96 | public function toPlainText(): string 97 | { 98 | return $this->cachedPlainText ??= PlainTextConversion::nodeToPlainText($this->source); 99 | } 100 | 101 | public function toHtml(): string 102 | { 103 | return $this->cachedHtml ??= HtmlConversion::nodeToHtml($this->source); 104 | } 105 | 106 | public function __toString(): string 107 | { 108 | return $this->toHtml(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/HtmlConversion.php: -------------------------------------------------------------------------------- 1 | ownerDocument->saveHTML($node); 13 | } 14 | 15 | public static function nodeToHtml(DOMDocument $node): string 16 | { 17 | return preg_replace("#\n*#", '', $node->saveHTML($node->documentElement)); 18 | } 19 | 20 | public static function fragmentForHtml(?string $html = null): Fragment 21 | { 22 | $document = static::document($html); 23 | 24 | return new Fragment($document); 25 | } 26 | 27 | public static function createElement($tagName, array $attributes = []): DOMElement 28 | { 29 | $element = static::document()->createElement($tagName); 30 | 31 | foreach ($attributes as $attr => $value) { 32 | $element->setAttribute($attr, $value); 33 | } 34 | 35 | return $element; 36 | } 37 | 38 | public static function document(?string $html = null): DOMDocument 39 | { 40 | libxml_use_internal_errors(true); 41 | $document = new DOMDocument('1.0', 'UTF-8'); 42 | 43 | if ($html) { 44 | // We're using a hack here to force the document encoding properly. 45 | // Then, we're going to remove the encoding tag from the document 46 | // right before returning it so we can have a clean document. 47 | // @see http://php.net/manual/en/domdocument.loadhtml.php#95251 48 | 49 | $document->loadHTML("{$html}", LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED); 50 | 51 | foreach ($document->childNodes as $item) { 52 | if ($item->nodeType == XML_PI_NODE) { 53 | $document->removeChild($item); 54 | } 55 | } 56 | 57 | $document->encoding = 'UTF-8'; 58 | } 59 | 60 | return $document; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Models/EncryptedRichText.php: -------------------------------------------------------------------------------- 1 | AsEncryptedRichTextContent::class, 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /src/Models/RichText.php: -------------------------------------------------------------------------------- 1 | AsRichTextContent::class, 19 | ]; 20 | 21 | public function record() 22 | { 23 | return $this->morphTo(); 24 | } 25 | 26 | public function __call($method, $arguments) 27 | { 28 | if (method_exists($this->body, $method)) { 29 | return call_user_func([$this->body, $method], ...$arguments); 30 | } 31 | 32 | return parent::__call($method, $arguments); 33 | } 34 | 35 | public function __toString(): string 36 | { 37 | return (string) $this->body->render(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Models/Traits/HasRichText.php: -------------------------------------------------------------------------------- 1 | getRichTextFields(); 17 | 18 | foreach ($fields as $field => $options) { 19 | static::registerRichTextRelationships($field, $options); 20 | } 21 | 22 | static::saving(function (Model $model): void { 23 | if (! $model::isIgnoringTouch()) { 24 | foreach ($model->getRichTextFields() as $field => $_options) { 25 | $relationship = static::fieldToRichTextRelationship($field); 26 | 27 | if ($model->relationLoaded($relationship) && $model->{$field}->isDirty() && $model->timestamps) { 28 | $model->updateTimestamps(); 29 | } 30 | } 31 | } 32 | }); 33 | 34 | static::saved(function (Model $model): void { 35 | foreach ($model->getRichTextFields() as $field => $_options) { 36 | $relationship = static::fieldToRichTextRelationship($field); 37 | 38 | if ($model->relationLoaded($relationship) && $model->{$field}->isDirty()) { 39 | $model->{$field}->record()->associate($model); 40 | $model->{$field}->save(); 41 | } 42 | } 43 | }); 44 | } 45 | 46 | protected static function registerRichTextRelationships(string $field, array $options = []): void 47 | { 48 | static::resolveRelationUsing(static::fieldToRichTextRelationship($field), function (Model $model) use ($field, $options) { 49 | $modelClass = ($options['encrypted'] ?? false) 50 | ? config('rich-text-laravel.encrypted_model') 51 | : config('rich-text-laravel.model'); 52 | 53 | return $model->morphOne($modelClass, 'record')->where('field', $field); 54 | }); 55 | } 56 | 57 | protected function initializeHasRichText() 58 | { 59 | foreach ($this->getRichTextFields() as $field => $_options) { 60 | $this->mergeCasts([ 61 | $field => ForwardsAttributeToRelationship::class, 62 | ]); 63 | } 64 | } 65 | 66 | protected function getRichTextFields(): array 67 | { 68 | if (! property_exists($this, 'richTextAttributes')) { 69 | throw RichTextException::missingRichTextFieldsProperty(static::class); 70 | } 71 | 72 | $fields = Collection::wrap($this->richTextAttributes); 73 | 74 | return $fields->mapWithKeys(fn ($value, $key) => is_string($key) ? [$key => $value] : [$value => []])->all(); 75 | } 76 | 77 | public function unsetRichTextRelationshipsForLivewireDehydration(): void 78 | { 79 | $relationships = array_map(fn ($field): string => static::fieldToRichTextRelationship($field), array_keys($this->getRichTextFields())); 80 | 81 | foreach ($relationships as $relationship) { 82 | if ($this->relationLoaded($relationship)) { 83 | $this->unsetRelation($relationship); 84 | } 85 | } 86 | } 87 | 88 | public static function fieldToRichTextRelationship(string $field): string 89 | { 90 | return 'richText'.Str::studly($field); 91 | } 92 | 93 | public function scopeWithRichText(Builder $query, $fields = []): void 94 | { 95 | $allFields = array_keys((new static)->getRichTextFields()); 96 | 97 | $fields = empty($fields) ? $allFields : $fields; 98 | 99 | // We're converting the attributes to the relationship pattern and 100 | // only then we'll perform the eager loading. If any of the given 101 | // fields is not a valid one, we'll throw an exception and halt. 102 | 103 | $fields = collect($fields) 104 | ->each(fn ($field) => throw_unless(in_array($field, $allFields), RichTextException::unknownRichTextFieldOnEagerLoading($field))) 105 | ->map(fn ($field): string => static::fieldToRichTextRelationship($field)) 106 | ->all(); 107 | 108 | $query->with($fields); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/PlainTextConversion.php: -------------------------------------------------------------------------------- 1 | nodeName)->studly()); 35 | } 36 | 37 | private static function plainTextForNodeChildren(DOMNode $node): string 38 | { 39 | $texts = []; 40 | $index = 0; 41 | 42 | foreach ($node->childNodes as $child) { 43 | /** @psalm-suppress TooManyArguments */ 44 | $texts[] = static::plainTextForNode($child, $index++); 45 | } 46 | 47 | return implode('', $texts); 48 | } 49 | 50 | private static function plainTextForBlock(DOMNode $node): string 51 | { 52 | return sprintf("%s\n\n", static::removeTrailingNewLines(static::plainTextForNodeChildren($node))); 53 | } 54 | 55 | private static function plainTextForH1Node(DOMNode $node): string 56 | { 57 | return static::plainTextForBlock($node); 58 | } 59 | 60 | private static function plainTextForH2Node(DOMNode $node): string 61 | { 62 | return static::plainTextForBlock($node); 63 | } 64 | 65 | private static function plainTextForH3Node(DOMNode $node): string 66 | { 67 | return static::plainTextForBlock($node); 68 | } 69 | 70 | private static function plainTextForH4Node(DOMNode $node): string 71 | { 72 | return static::plainTextForBlock($node); 73 | } 74 | 75 | private static function plainTextForH5Node(DOMNode $node): string 76 | { 77 | return static::plainTextForBlock($node); 78 | } 79 | 80 | private static function plainTextForH6Node(DOMNode $node): string 81 | { 82 | return static::plainTextForBlock($node); 83 | } 84 | 85 | private static function plainTextForPNode(DOMNode $node): string 86 | { 87 | return static::plainTextForBlock($node); 88 | } 89 | 90 | private static function plainTextForUlNode(DOMNode $node): string 91 | { 92 | return static::plainTextForList($node); 93 | } 94 | 95 | private static function plainTextForOlNode(DOMNode $node): string 96 | { 97 | return static::plainTextForList($node); 98 | } 99 | 100 | private static function plainTextForBrNode(): string 101 | { 102 | return "\n"; 103 | } 104 | 105 | private static function plainTextForList(DOMNode $node): string 106 | { 107 | return static::breakIfNestedList($node, static::plainTextForBlock($node)); 108 | } 109 | 110 | private static function plainTextForTextNode(DOMText $node): string 111 | { 112 | return static::removeTrailingNewLines($node->ownerDocument->saveHTML($node)); 113 | } 114 | 115 | private static function plainTextForDivNode(DOMNode $node): string 116 | { 117 | return sprintf("%s\n\n", static::removeTrailingNewLines(static::plainTextForNodeChildren($node))); 118 | } 119 | 120 | private static function plainTextForFigcaptionNode(DOMNode $node): string 121 | { 122 | return sprintf('[%s]', static::removeTrailingNewLines(static::plainTextForNodeChildren($node))); 123 | } 124 | 125 | private static function plainTextForBlockquoteNode(DOMNode $node): ?string 126 | { 127 | $text = static::plainTextForBlock($node); 128 | 129 | return preg_replace('/\A(\s*)(.+?)(\s*)\Z/m', '\1“\2”\3', $text); 130 | } 131 | 132 | private static function plainTextForLiNode(DOMNode $node, $index = 0): string 133 | { 134 | $bullet = static::bulletForLiNode($node, $index); 135 | $text = static::removeTrailingNewLines(static::plainTextForNodeChildren($node)); 136 | $indentation = static::indentationForLiNode($node); 137 | 138 | return sprintf("%s%s %s\n", $indentation, $bullet, $text); 139 | } 140 | 141 | private static function plainTextForPreNode(DOMNode $node): string 142 | { 143 | return static::plainTextForBlock($node); 144 | } 145 | 146 | private static function bulletForLiNode(DOMNode $node, $index): string 147 | { 148 | if ($node->parentNode->nodeName === 'ol') { 149 | return sprintf('%s.', $index + 1); 150 | } 151 | 152 | return '•'; 153 | } 154 | 155 | private static function breakIfNestedList(DOMNode $node, string $text): string 156 | { 157 | if (static::listNodeDepthForNode($node) > 0) { 158 | return "\n{$text}"; 159 | } 160 | 161 | return $text; 162 | } 163 | 164 | private static function indentationForLiNode(DOMNode $node): string 165 | { 166 | $depth = static::listNodeDepthForNode($node); 167 | 168 | if ($depth > 1) { 169 | return str_repeat(' ', $depth - 1); 170 | } 171 | 172 | return ''; 173 | } 174 | 175 | private static function listNodeDepthForNode(DOMNode $node): int 176 | { 177 | preg_match_all('#/[uo]l/#', (string) $node->getNodePath(), $matches); 178 | 179 | if (! isset($matches[0])) { 180 | return 1; 181 | } 182 | 183 | return count($matches[0]); 184 | } 185 | 186 | private static function removeTrailingNewLines(string $text): string 187 | { 188 | return trim($text, "\n\r"); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/RichTextLaravel.php: -------------------------------------------------------------------------------- 1 | Crypt::encryptString($value); 58 | 59 | return call_user_func($encrypt, $value, $model, $key); 60 | } 61 | 62 | public static function decrypt($value, $model, $key): ?string 63 | { 64 | $decrypt = static::$decryptHandler ??= fn ($value) => Crypt::decryptString($value); 65 | 66 | return $value ? call_user_func($decrypt, $value, $model, $key) : $value; 67 | } 68 | 69 | public static function withCustomAttachables(Closure|callable|null $customAttachablesResolver): void 70 | { 71 | static::$customAttachablesResolver = $customAttachablesResolver; 72 | } 73 | 74 | public static function clearCustomAttachables(): void 75 | { 76 | static::withCustomAttachables(null); 77 | } 78 | 79 | public static function attachableFromCustomResolver(DOMElement $node): ?AttachableContract 80 | { 81 | $resolver = static::$customAttachablesResolver ?? fn (): null => null; 82 | 83 | return $resolver($node); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/RichTextLaravelServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('rich-text-laravel') 21 | ->hasConfigFile() 22 | ->hasViews() 23 | ->hasMigration('create_rich_texts_table') 24 | ->hasCommand(InstallCommand::class); 25 | } 26 | 27 | public function packageBooted(): void 28 | { 29 | $this->callAfterResolving('blade.compiler', function (BladeCompiler $blade): void { 30 | $blade->anonymousComponentPath(dirname(__DIR__).implode(DIRECTORY_SEPARATOR, ['', 'resources', 'views', 'components']), 'rich-text'); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Serialization.php: -------------------------------------------------------------------------------- 1 | toHtml(); 24 | } 25 | 26 | return (new static($content))->toHtml(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/TrixAttachment.php: -------------------------------------------------------------------------------- 1 | setAttribute('data-trix-attachment', json_encode($trixAttachmentAttributes)); 31 | 32 | if ($trixAttributes) { 33 | $node->setAttribute('data-trix-attributes', json_encode($trixAttributes ?: [])); 34 | } 35 | 36 | return new static($node); 37 | } 38 | 39 | private static function processAttributes(array $attributes): array 40 | { 41 | return collect($attributes) 42 | ->mapWithKeys(function ($value, $key) { 43 | $newKey = (string) Str::of($key)->camel(); 44 | 45 | return [$newKey => static::typeCast($newKey, $value)]; 46 | }) 47 | ->only(static::ATTRIBUTES) 48 | ->all(); 49 | } 50 | 51 | private static function typeCast(string $key, $value) 52 | { 53 | return match ($key) { 54 | 'previewable' => $value === true || $value === 'true', 55 | 'filesize', 'height', 'width' => is_numeric($value) ? intval($value) : $value, 56 | default => "{$value}", 57 | }; 58 | } 59 | 60 | public function __construct(public DOMElement $node) {} 61 | 62 | public function attributes(): array 63 | { 64 | return $this->attributesCache ??= collect($this->attachmentAttributes()) 65 | ->merge($this->composedAttributes()) 66 | ->only(static::ATTRIBUTES) 67 | ->all(); 68 | } 69 | 70 | public function toHtml(): string 71 | { 72 | return $this->node->ownerDocument->saveHTML($this->node); 73 | } 74 | 75 | private function attachmentAttributes(): array 76 | { 77 | return $this->readJsonAttribute('data-trix-attachment'); 78 | } 79 | 80 | private function composedAttributes(): array 81 | { 82 | return $this->readJsonAttribute('data-trix-attributes'); 83 | } 84 | 85 | private function readJsonAttribute(string $key): array 86 | { 87 | if (! $this->node->hasAttribute($key)) { 88 | return []; 89 | } 90 | 91 | $value = $this->node->getAttribute($key); 92 | $data = json_decode($value ?: '[]', true); 93 | 94 | if (json_last_error() !== JSON_ERROR_NONE) { 95 | Log::notice(sprintf( 96 | '[%s] Couldnt parse JSON %s from NODE %s', 97 | static::class, 98 | $value, 99 | $this->node->tagName, 100 | )); 101 | 102 | return []; 103 | } 104 | 105 | return $data; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/View/Components/TrixStyles.php: -------------------------------------------------------------------------------- 1 | '']) 2 | 3 | 9 | 10 | 14 | 15 | merge(['class' => 'trix-content border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:ring-1 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm dark:[&_pre]:!bg-gray-700 dark:[&_pre]:rounded dark:[&_pre]:!text-white']) }} 20 | > 21 | --------------------------------------------------------------------------------