Skip to content

Commit

Permalink
Merge pull request #715 from CamKem/feat/link-previews
Browse files Browse the repository at this point in the history
Feat: Link Previews (OG Images & Video + oEmbed Videos & Tweets)
  • Loading branch information
nunomaduro authored Nov 2, 2024
2 parents a86cca0 + 74ed753 commit 397c465
Show file tree
Hide file tree
Showing 12 changed files with 450 additions and 14 deletions.
178 changes: 178 additions & 0 deletions app/Services/MetaData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

declare(strict_types=1);

namespace App\Services;

use DOMDocument;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

final readonly class MetaData
{
/**
* Fetch the Open Graph data for a given URL.
*/
public function __construct(private string $url)
{
//
}

/**
* Fetch the parsed meta-data for the URL
*
* @return Collection<string, string>
*/
public function fetch(): Collection
{
/** @var Collection<string, string> $cachedData */
$cachedData = Cache::remember(
Str::of($this->url)->slug()->prepend('preview_')->value(),
now()->addYear(),
fn (): Collection => $this->getData()
);

return $cachedData;
}

/**
* Get the meta-data for a given URL.
*
* @return Collection<string, string>
*/
private function getData(): Collection
{
$data = collect();

try {
$response = Http::get($this->url);

if ($response->ok()) {
$data = $this->parse($response->body())
->filter(fn ($value): bool => $value !== '');
}
} catch (ConnectionException) {
// Catch but not capture the exception
}

return $data;
}

/**
* Fetch the oEmbed data for a given URL.
*
* @param array<string, string> $options
* @return Collection<string, string>
*/
private function fetchOEmbed(string $service, array $options): Collection
{
$data = collect();

try {
$response = Http::get(
url: $service.'?url='.urlencode($this->url).'&'.http_build_query($options)
);

if ($response->ok()) {
$data = collect((array) $response->json())
->filter(fn ($value): bool => $value !== '');
}
} catch (ConnectionException) {
// Catch but not capture the exception
}

return $data;
}

/**
* Parse the response body for MetaData.
*
* @return Collection<string, string>
*/
private function parse(string $content): Collection
{
$doc = new DOMDocument();
@$doc->loadHTML($content);

$interested_in = ['og', 'twitter'];
$allowed = ['title', 'description', 'keywords', 'image', 'site_name', 'url', 'type'];
$data = collect();
$metas = $doc->getElementsByTagName('meta');

if ($metas->count() > 0) {
foreach ($metas as $meta) {
if (mb_strtolower($meta->getAttribute('name')) === 'title') {
$data->put('title', $meta->getAttribute('content'));
}

if (mb_strtolower($meta->getAttribute('name')) === 'description') {
$data->put('description', $meta->getAttribute('content'));
}
if (mb_strtolower($meta->getAttribute('name')) === 'keywords') {
$data->put('keywords', $meta->getAttribute('content'));
}

collect(['name', 'property'])
->map(fn ($name): string => $meta->getAttribute($name))
->filter(fn ($attribute): bool => in_array(explode(':', $attribute)[0], $interested_in))
->each(function ($attribute) use ($data, $allowed, $meta): void {
$key = explode(':', $attribute)[1];
if (! $data->has($key) && in_array($key, $allowed, true)) {
$data->put($key, $meta->getAttribute('content'));
}
});
}
}

if ($data->has('site_name') && $data->get('site_name') === 'X (formerly Twitter)') {
$x = $this->fetchOEmbed(
service: 'https://publish.twitter.com/oembed',
options: [
'dnt' => 'true',
'omit_script' => 'true',
'hide_thread' => 'true',
'maxwidth' => '446',
'maxheight' => '251',
]);
if ($x->isNotEmpty()) {
foreach ($x as $key => $value) {
$data->put($key, $value);
}
}
}

if ($data->has('site_name') && $data->get('site_name') === 'Vimeo') {
$vimeo = $this->fetchOEmbed(
service: 'https://vimeo.com/api/oembed.json',
options: [
'maxwidth' => '446',
'maxheight' => '251',
]
);
if ($vimeo->isNotEmpty()) {
foreach ($vimeo as $key => $value) {
$data->put($key, $value);
}
}
}

if ($data->has('site_name') && $data->get('site_name') === 'YouTube') {
$youtube = $this->fetchOEmbed(
service: 'https://www.youtube.com/oembed',
options: [
'maxwidth' => '446',
'maxheight' => '251',
]);
if ($youtube->isNotEmpty()) {
foreach ($youtube as $key => $value) {
$data->put($key, $value);
}
}
}

return $data->unique();
}
}
17 changes: 17 additions & 0 deletions app/Services/ParsableContentProviders/LinkProviderParsable.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Services\ParsableContentProviders;

use App\Contracts\Services\ParsableContentProvider;
use App\Services\MetaData;
use Illuminate\Support\Str;

final readonly class LinkProviderParsable implements ParsableContentProvider
Expand Down Expand Up @@ -37,6 +38,22 @@ function (array $matches): string {

$url = $isMail ? 'mailto:'.$humanUrl : $url;

if (! $isMail && $url) {
$service = new MetaData($url);
$metadata = $service->fetch();

if ($metadata->isNotEmpty() && ($metadata->has('image') || $metadata->has('html'))) {
$trimmed = trim(
view('components.link-preview-card', [
'data' => $metadata,
'url' => $url,
])->render()
);

return (string) preg_replace('/<!--(.|\s)*?-->/', '', $trimmed);
}
}

return '<a data-navigate-ignore="true" class="text-blue-500 hover:underline hover:text-blue-700 cursor-pointer" target="_blank" href="'.$url.'">'.$humanUrl.'</a>';
},
str_replace('&amp;', '&', $content)
Expand Down
54 changes: 44 additions & 10 deletions resources/js/theme-switch.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
const themeSwitch = () => ({

theme: 'system',
currentTheme: null,

init() {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme);

const currentTheme = localStorage.getItem('theme') || this.theme;

this.setTheme(currentTheme);
const savedTheme = localStorage.getItem('theme') || this.theme;
this.setTheme(savedTheme);
},

setTheme(theme) {
this.theme = theme
this.theme = theme;

if (theme == 'dark' || theme == 'light') {
localStorage.setItem('theme', theme)
if (theme === 'dark' || theme === 'light') {
localStorage.setItem('theme', theme);
} else {
localStorage.removeItem('theme');
}

updateTheme();
this.updateTheme();
},

getCurrentTheme() {
if (this.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return this.theme;
},

updateTheme() {
const newTheme = this.getCurrentTheme();

document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(newTheme);

if (this.currentTheme !== newTheme) {
this.currentTheme = newTheme;
this.renderTweets(newTheme);
}
},

renderTweets(theme) {
const tweetContainers = document.querySelectorAll('div[data-tweet-id]');

if (tweetContainers.length > 0) {
tweetContainers.forEach(container => {
container.innerHTML = '';

window.twttr.widgets.createTweet(container.dataset.tweetId, container, {
theme: theme,
conversation: 'none',
align: 'center',
});
});
}
},
})
});

export { themeSwitch }
export { themeSwitch };
42 changes: 42 additions & 0 deletions resources/views/components/link-preview-card.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<div
id="link-preview-card"
data-url="{{ $url }}"
class="mx-auto mt-2 min-w-full group/preview" data-navigate-ignore="true"
>
@if ($data->has('html'))
@if ($data->get('site_name') === 'X (formerly Twitter)')
@php($tweetId = explode('/', $url))
<div data-tweet-id="{{ end($tweetId) }}" class="w-full overflow-hidden">
{!! $data->get('html') !!}
</div>
@else
<div class="w-full overflow-hidden rounded-lg border border-slate-300 dark:border-0">
{!! $data->get('html') !!}
</div>
@endif
@elseif($data->has('image'))
@php($shortUrl = parse_url($url)['host'])
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer">
<div
title="Click to visit: {{ $shortUrl }}"
class="relative w-full bg-slate-100/90 border border-slate-300
dark:border-0 rounded-lg dark:group-hover/preview:border-0 overflow-hidden">
<img
src="{{ $data->get('image') }}"
alt="{{ $data->get('title') ?? $url }}"
class="object-cover object-center w-full h-56"
/>
<div
class="absolute right-0 bottom-0 left-0 w-full rounded-b-lg border-0 bg-pink-100 bg-opacity-75 p-2 backdrop-blur-sm backdrop-filter dark:bg-opacity-45 dark:bg-pink-800">
<h3 class="text-sm font-semibold truncate text-slate-500/90 dark:text-white/90
">
{{ $data->get('title') ?? $data->get('site_name') ?? $url }}</h3>
</div>
</div>
</a>
<div class="flex items-center justify-between pt-4">
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer"
class="text-xs text-slate-500 group-hover/preview:text-pink-600">From: {{ $shortUrl}}</a>
</div>
@endif
</div>
2 changes: 2 additions & 0 deletions resources/views/layouts/components/head.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@
}
</style>

<script id="twitter-widget" async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<script>
function isDarkTheme() {
return localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
Expand Down
17 changes: 16 additions & 1 deletion resources/views/livewire/questions/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -318,14 +318,28 @@ class="text-slate-500 transition-colors dark:hover:text-slate-400 hover:text-sla
>
<x-heroicon-o-link class="size-4" />
</button>
@php
$sharableQuestion = str_replace("'", "\'", $question->isSharedUpdate() ? $question->answer : $question->content);
$link = null;
if (preg_match('/<div\s+id="link-preview-card"[^>]*>(.*)<\/div>(?!.*<\/div>)/si', $sharableQuestion, $matches)) {
$linkPreviewCard = $matches[0];
if (preg_match('/data-url="([^"]*)"/', $linkPreviewCard, $urlMatches)) {
$link = " {$urlMatches[1]} ";
}
}
$sharable = $link ? str_replace($linkPreviewCard, $link, $sharableQuestion) : $sharableQuestion;
@endphp
<button
data-navigate-ignore="true"
x-cloak
x-data="shareProfile"
x-on:click="
twitter({
url: '{{ route('questions.show', ['username' => $question->to->username, 'question' => $question]) }}',
question: '{{ str_replace("'", "\'", $question->isSharedUpdate() ? $question->answer : $question->content) }}',
question: '{{ $sharable }}',
message: '{{ $question->isSharedUpdate() ? 'See it on Pinkary' : 'See response on Pinkary' }}',
})
"
Expand Down Expand Up @@ -377,6 +391,7 @@ class="text-slate-500 transition-colors dark:hover:text-slate-400 hover:text-sla
</div>
</div>
</x-modal>

@elseif (auth()->user()?->is($user))
<livewire:questions.edit
:questionId="$question->id"
Expand Down
Loading

0 comments on commit 397c465

Please sign in to comment.