Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support custom content attachments that dont require an sgid #56

Merged
merged 7 commits into from
Apr 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,55 @@ $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:

```php
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:

```php
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](https://github.com/tonysm/rich-text-laravel/pull/56)).

### Plain Text Rendering
<a name="plain-text"></a>

Expand Down
4 changes: 4 additions & 0 deletions src/AttachableFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class AttachableFactory
{
public static function fromNode(DOMElement $node): Attachables\AttachableContract
{
if ($attachable = RichTextLaravel::attachableFromCustomResolver($node)) {
return $attachable;
}

if ($node->hasAttribute('sgid') && $attachable = static::attachableFromSgid($node->getAttribute('sgid'))) {
return $attachable;
}
Expand Down
27 changes: 27 additions & 0 deletions src/RichTextLaravel.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

namespace Tonysm\RichTextLaravel;

use Closure;
use DOMElement;
use Illuminate\Support\Facades\Crypt;
use Tonysm\RichTextLaravel\Attachables\AttachableContract;

class RichTextLaravel
{
Expand All @@ -20,6 +23,13 @@ class RichTextLaravel
*/
protected static $decryptHandler;

/**
* The custom content attachment resolver (if any).
*
* @var callable
*/
protected static $customAttachablesResolver;

/**
* Override the way the package handles encryption.
*/
Expand Down Expand Up @@ -55,4 +65,21 @@ public static function decrypt($value, $model, $key): ?string

return $value ? call_user_func($decrypt, $value, $model, $key) : $value;
}

public static function withCustomAttachables(Closure|callable|null $customAttachablesResolver): void
{
static::$customAttachablesResolver = $customAttachablesResolver;
}

public static function clearCustomAttachables(): void
{
static::withCustomAttachables(null);
}

public static function attachableFromCustomResolver(DOMElement $node): ?AttachableContract
{
$resolver = static::$customAttachablesResolver ?? fn () => null;

return $resolver($node);
}
}
25 changes: 25 additions & 0 deletions tests/ContentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Tonysm\RichTextLaravel\Attachables\RemoteImage;
use Tonysm\RichTextLaravel\Attachment;
use Tonysm\RichTextLaravel\Content;
use Workbench\App\Models\Opengraph\OpengraphEmbed;
use Workbench\App\Models\User;
use Workbench\Database\Factories\UserFactory;

Expand Down Expand Up @@ -544,6 +545,30 @@ public function renders_horizontal_rules_for_trix()
HTML, $content->toTrixHtml());
}

/** @test */
public function supports_custom_content_attachments_without_sgid()
{
$contentType = OpengraphEmbed::CONTENT_TYPE;

$content = $this->fromHtml(<<<HTML
<div>
Testing out with cards: <a href="https://github.com/tonysm/rich-text-laravel">https://github.com/tonysm/rich-text-laravel</a>
<rich-text-attachment
caption="Integrates the Trix Editor with Laravel. Inspired by the Action Text gem from Rails. - tonysm/rich-text-laravel"
content-type="{$contentType}"
filename="GitHub - tonysm/rich-text-laravel: Integrates the Trix Editor with Laravel. Inspired by the Action Text gem from Rails."
href="https://github.com/tonysm/rich-text-laravel"
url="https://opengraph.githubassets.com/7e956bd233205f222790d8cdbdadfc401886aabdc88a8fd0cfb3c7dcca44d635/tonysm/rich-text-laravel"
></rich-text-attachment>
</div>
HTML);

$this->assertCount(1, $content->attachments());
$this->assertCount(1, $content->attachables());
$this->assertInstanceOf(OpengraphEmbed::class, $content->attachables()->first());
$this->assertEquals('https://github.com/tonysm/rich-text-laravel', $content->attachables()->first()->href);
}

private function withAttachmentTagName(string $tagName, callable $callback)
{
try {
Expand Down
106 changes: 106 additions & 0 deletions workbench/app/Models/Opengraph/OpengraphEmbed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace Workbench\App\Models\Opengraph;

use DOMElement;
use Tonysm\RichTextLaravel\Attachables\AttachableContract;

class OpengraphEmbed implements AttachableContract
{
use OpengraphEmbed\Fetching;

const ATTRIBUTES = ['title', 'url', 'image', 'description'];

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;
}

public static function tryFromAttributes(array $attributes)
{
if (validator($attributes, [
'title' => ['required'],
'url' => ['required'],
'description' => ['required'],
'image' => ['sometimes', 'required', 'url'],
])->fails()) {
return null;
}

return new static(
$attributes['url'],
$attributes['image'] ?? null,
$attributes['title'],
$attributes['description'],
);
}

private static function attributesFromNode(DOMElement $node): array
{
return [
'href' => $node->getAttribute('href'),
'url' => $node->getAttribute('url'),
'filename' => $node->getAttribute('filename'),
'description' => $node->getAttribute('caption'),
];
}

public function __construct(
public $href,
public $url,
public $filename,
public $description,
) {
}

public function toRichTextAttributes(array $attributes): array
{
return collect($attributes)
->replace([
'content_type' => $this->richTextContentType(),
'previewable' => true,
])
->filter()
->all();
}

public function equalsToAttachable(AttachableContract $attachable): bool
{
return $this->richTextRender() === $attachable->richTextRender();
}

public function richTextRender(array $options = []): string
{
return view('rich-text-laravel.attachables.opengraph_embed', [
'attachable' => $this,
])->render();
}

public function richTextAsPlainText(?string $caption = null): string
{
return '';
}

public function richTextContentType(): string
{
return static::CONTENT_TYPE;
}

public function toArray(): array
{
return [
'href' => $this->href,
'url' => $this->url,
'filename' => $this->filename,
'description' => $this->description,
'contentType' => $this->richTextContentType(),
'content' => $this->richTextRender(),
];
}
}
69 changes: 69 additions & 0 deletions workbench/app/Models/Opengraph/OpengraphEmbed/Fetching.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace Workbench\App\Models\Opengraph\OpengraphEmbed;

use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\Http;
use Tonysm\RichTextLaravel\HtmlConversion;
use Workbench\App\Models\Opengraph\OpengraphEmbed;

trait Fetching
{
public static function createFromUrl(string $url): ?OpengraphEmbed
{
$attributes = static::extractAttributesFromDocument(static::fetchDocument($url));

return OpengraphEmbed::tryFromAttributes($attributes);
}

private static function fetchDocument(string $url)
{
return HtmlConversion::document(
Http::withUserAgent('curl/7.81.0')
->maxRedirects(10)
->get(static::replaceTwitterDomainFromTweetUrls($url))
->throw()
->body()
);
}

private static function replaceTwitterDomainFromTweetUrls(string $url)
{
$domains = [
'www.x.com',
'www.twitter.com',
'twitter.com',
'x.com',
];

$host = parse_url($url)['host'];

return in_array($host, $domains, strict: true)
? str_replace($host, 'fxtwitter.com', $url)
: $url;
}

private static function extractAttributesFromDocument(DOMDocument $document): array
{
$xpath = new DOMXPath($document);
$openGraphTags = $xpath->query('//meta[starts-with(@property, "og:") or starts-with(@name, "og:")]');
$attributes = [];

foreach ($openGraphTags as $tag) {
if (! $tag->hasAttribute('content')) {
continue;
}

$key = str_replace('og:', '', $tag->hasAttribute('property') ? $tag->getAttribute('property') : $tag->getAttribute('name'));

if (! in_array($key, OpengraphEmbed::ATTRIBUTES, true)) {
continue;
}

$attributes[$key] = $tag->getAttribute('content');
}

return $attributes;
}
}
13 changes: 13 additions & 0 deletions workbench/app/Providers/WorkbenchServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

namespace Workbench\App\Providers;

use DOMElement;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Livewire\LivewireManager;
use Tonysm\RichTextLaravel\RichTextLaravel;
use Workbench\App\Livewire\Posts;
use Workbench\App\Models\Opengraph\OpengraphEmbed;

class WorkbenchServiceProvider extends ServiceProvider
{
Expand All @@ -23,6 +26,7 @@ public function register(): void
public function boot(): void
{
$this->registerComponents();
$this->registerCustomRichTextAttachables();
}

public function registerComponents(): void
Expand All @@ -31,4 +35,13 @@ public function registerComponents(): void
$livewire->component('posts.index', Posts::class);
});
}

public function registerCustomRichTextAttachables(): void
{
RichTextLaravel::withCustomAttachables(function (DOMElement $node) {
if ($attachable = OpengraphEmbed::fromNode($node)) {
return $attachable;
}
});
}
}
7 changes: 4 additions & 3 deletions workbench/resources/views/chat/partials/trix-input.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

<trix-editor
placeholder="Say something nice..."
data-controller="rich-text-mentions oembed"
data-oembed-target="text"
data-composer-target="text"
data-controller="rich-text-mentions"
data-action="keydown->composer#submitByKeyboard tribute-replaced->rich-text-mentions#addMention tribute-active-true->composer#disableSubmitByKeyboard tribute-active-false->composer#enableSubmitByKeyboard trix-attachment-add->composer#rejectFiles"
data-action="trix-paste->oembed#pasted keydown->composer#submitByKeyboard tribute-replaced->rich-text-mentions#addMention tribute-active-true->composer#disableSubmitByKeyboard tribute-active-false->composer#enableSubmitByKeyboard trix-attachment-add->composer#rejectFiles"
id="create_message"
name="content"
toolbar="create_message_toolbar"
input="create_message_input"
class="trix-content overflow-auto rounded-0 p-0 [&_pre]:text-sm min-h-0 max-h-[12vh] border-0 sm:group-data-[composer-show-toolbar-value=true]:py-2 sm:group-data-[composer-show-toolbar-value=true]:min-h-[4em]"
class="trix-content overflow-auto rounded-0 p-0 [&_pre]:text-sm min-h-0 max-h-[90vh] border-0 sm:group-data-[composer-show-toolbar-value=true]:py-2 sm:group-data-[composer-show-toolbar-value=true]:min-h-[4em]"
></trix-editor>

<trix-toolbar js-cloak id="create_message_toolbar" class="[&_.trix-button-group]:!mb-0 sm:group-data-[composer-show-toolbar-value=true]:mt-2">
Expand Down
Loading
Loading