Skip to content

Commit 0c1081a

Browse files
simonhampclaude
andauthored
Add OpenGraph images and SEO meta tags to plugin pages (#407)
* Add OpenGraph images and SEO meta tags to plugin pages Plugin listing pages had no per-plugin <title>/OpenGraph tags, so social shares fell back to the generic site card. This adds: - SEO/OG/Twitter meta (title, description, image) on the plugin show and license pages, mirroring the blog controller pattern. - Generated OG images via TheOg (like blog posts): a queued GeneratePluginOgImage job renders to the public disk and stores the URL on a new plugins.og_image column. Dispatched from PluginSyncService and Filament EditPlugin so images stay fresh. - A PluginOgLayout that renders plugin version, required NativePHP Mobile constraint, and iOS/Android minimums as rounded "pill" badges (composed from rectangles + circles via a PillBox feature, since Intervention has no rounded-rect primitive). - A plugins:generate-og-images command to backfill existing plugins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Mock SatisService in plugin OG image deletion test The plugin deleting hook resolves SatisService, whose constructor assigns the (string-typed) api key from config. In CI SATIS_API_KEY is unset, so the null config value triggered a TypeError. Mock the service in the deletion test (matching existing Satis test conventions) since the test only exercises OG image cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 700e2cc commit 0c1081a

13 files changed

Lines changed: 654 additions & 32 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Jobs\GeneratePluginOgImage;
6+
use App\Models\Plugin;
7+
use Illuminate\Console\Command;
8+
use Illuminate\Support\Facades\Storage;
9+
10+
class GeneratePluginOgImages extends Command
11+
{
12+
protected $signature = 'plugins:generate-og-images
13+
{--missing : Only generate images that do not already exist on disk}';
14+
15+
protected $description = 'Generate (or regenerate) the OpenGraph images for plugins';
16+
17+
public function handle(): int
18+
{
19+
$plugins = Plugin::query()->get();
20+
21+
if ($this->option('missing')) {
22+
$plugins = $plugins->reject(
23+
fn (Plugin $plugin) => Storage::disk('public')->exists("og-images/plugins/{$plugin->id}.png")
24+
);
25+
}
26+
27+
if ($plugins->isEmpty()) {
28+
$this->info('No plugins need OG images.');
29+
30+
return self::SUCCESS;
31+
}
32+
33+
$this->withProgressBar($plugins, function (Plugin $plugin): void {
34+
GeneratePluginOgImage::dispatchSync($plugin);
35+
});
36+
37+
$this->newLine(2);
38+
$this->info("Generated OG images for {$plugins->count()} plugin(s).");
39+
40+
return self::SUCCESS;
41+
}
42+
}

app/Filament/Resources/PluginResource/Pages/EditPlugin.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Enums\PluginTier;
77
use App\Enums\PluginType;
88
use App\Filament\Resources\PluginResource;
9+
use App\Jobs\GeneratePluginOgImage;
910
use App\Jobs\ReviewPluginRepository;
1011
use App\Jobs\SyncPlugin;
1112
use App\Jobs\SyncPluginReleases;
@@ -23,6 +24,11 @@ class EditPlugin extends EditRecord
2324
{
2425
protected static string $resource = PluginResource::class;
2526

27+
protected function afterSave(): void
28+
{
29+
GeneratePluginOgImage::dispatch($this->record);
30+
}
31+
2632
public function getSubheading(): string|HtmlString|null
2733
{
2834
$color = match ($this->record->status) {

app/Http/Controllers/PluginDirectoryController.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Models\Plugin;
66
use App\Models\PluginBundle;
7+
use Artesaos\SEOTools\Facades\SEOTools;
78
use Illuminate\Support\Facades\Auth;
89
use Illuminate\View\View;
910

@@ -71,6 +72,8 @@ public function show(string $vendor, string $package): View
7172
$bestPrice = $plugin->getBestPriceForUser($user);
7273
$regularPrice = $plugin->getRegularPrice();
7374

75+
$this->setPluginSeo($plugin);
76+
7477
return view('plugin-show', [
7578
'plugin' => $plugin,
7679
'bundles' => $bundles,
@@ -99,9 +102,32 @@ public function license(string $vendor, string $package): View
99102
abort(404);
100103
}
101104

105+
$this->setPluginSeo($plugin, suffix: 'License');
106+
102107
return view('plugin-license', [
103108
'plugin' => $plugin,
104109
'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isAdmin || $isOwner),
105110
]);
106111
}
112+
113+
protected function setPluginSeo(Plugin $plugin, string $suffix = 'Plugin'): void
114+
{
115+
$name = $plugin->display_name ?? $plugin->name;
116+
$description = $plugin->description ?: "{$name} is a plugin for NativePHP Mobile.";
117+
118+
SEOTools::setTitle("{$name} - {$suffix}");
119+
SEOTools::setDescription($description);
120+
121+
SEOTools::opengraph()->setTitle($name);
122+
SEOTools::opengraph()->setDescription($description);
123+
SEOTools::opengraph()->setType('website');
124+
125+
SEOTools::twitter()->setTitle($name);
126+
SEOTools::twitter()->setDescription($description);
127+
128+
if ($plugin->og_image) {
129+
SEOTools::opengraph()->addImage($plugin->og_image);
130+
SEOTools::twitter()->setImage($plugin->og_image);
131+
}
132+
}
107133
}

app/Jobs/GeneratePluginOgImage.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Models\Plugin;
6+
use App\Services\OgImageService;
7+
use Illuminate\Bus\Queueable;
8+
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Foundation\Bus\Dispatchable;
10+
use Illuminate\Queue\InteractsWithQueue;
11+
use Illuminate\Queue\SerializesModels;
12+
13+
class GeneratePluginOgImage implements ShouldQueue
14+
{
15+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
16+
17+
public function __construct(public Plugin $plugin) {}
18+
19+
public function handle(OgImageService $ogImageService): void
20+
{
21+
$ogImageUrl = $ogImageService->generateForPlugin($this->plugin);
22+
23+
$this->plugin->update([
24+
'og_image' => $ogImageUrl,
25+
]);
26+
}
27+
}

app/Models/Plugin.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use App\Jobs\SendNewPluginNotifications;
1111
use App\Notifications\PluginApproved;
1212
use App\Notifications\PluginRejected;
13+
use App\Services\OgImageService;
1314
use App\Services\PluginSyncService;
1415
use App\Services\SatisService;
1516
use Illuminate\Database\Eloquent\Attributes\Scope;
@@ -82,6 +83,8 @@ protected static function booted(): void
8283
if ($plugin->name) {
8384
resolve(SatisService::class)->removePackage($plugin->name);
8485
}
86+
87+
resolve(OgImageService::class)->deleteForPlugin($plugin);
8588
});
8689
}
8790

app/Services/OgImageService.php

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace App\Services;
44

55
use App\Models\Article;
6+
use App\Models\Plugin;
67
use Illuminate\Support\Facades\Storage;
78
use Intervention\Image\Colors\Rgb\Color;
89
use SimonHamp\TheOg\BorderPosition;
910
use SimonHamp\TheOg\Image;
11+
use SimonHamp\TheOg\Interfaces\Layout;
1012

1113
class OgImageService
1214
{
@@ -15,56 +17,87 @@ class OgImageService
1517
*/
1618
public function generate(Article $article): string
1719
{
18-
$image = new Image;
19-
20-
// Ensure the directory exists
21-
$directory = Storage::disk('public')->path('og-images');
22-
if (! is_dir($directory)) {
23-
mkdir($directory, 0755, true);
24-
}
25-
26-
$image
27-
->layout(new NativePhpLayout)
28-
->title($article->title)
29-
->description($article->excerpt ?? '')
30-
->backgroundColor('#ffffff')
31-
->titleColor('#141624')
32-
->descriptionColor('#141624')
33-
->border(BorderPosition::All, new Color(80, 91, 147), 5)
34-
->watermark(public_path('logo.svg'))
35-
->url(url('/blog/'.$article->slug))
36-
->save($this->getImagePath($article));
20+
return $this->render(
21+
"og-images/{$article->slug}.png",
22+
$article->title,
23+
$article->excerpt ?? '',
24+
url('/blog/'.$article->slug),
25+
);
26+
}
3727

38-
return $this->getImageUrl($article);
28+
/**
29+
* Generate an OG image for the given plugin.
30+
*/
31+
public function generateForPlugin(Plugin $plugin): string
32+
{
33+
return $this->render(
34+
"og-images/plugins/{$plugin->id}.png",
35+
$plugin->display_name ?? $plugin->name,
36+
$plugin->description ?? '',
37+
route('plugins.show', $plugin->routeParams()),
38+
new PluginOgLayout(
39+
version: $plugin->latest_version,
40+
mobileVersion: $plugin->mobile_min_version,
41+
iosVersion: $plugin->ios_version,
42+
androidVersion: $plugin->android_version,
43+
),
44+
);
3945
}
4046

4147
/**
4248
* Delete the OG image for the given article.
4349
*/
4450
public function delete(Article $article): bool
4551
{
46-
if ($article->og_image) {
47-
$path = str_replace('/storage/', '', parse_url($article->og_image, PHP_URL_PATH));
48-
49-
return Storage::disk('public')->delete($path);
50-
}
52+
return $this->deleteByUrl($article->og_image);
53+
}
5154

52-
return false;
55+
/**
56+
* Delete the OG image for the given plugin.
57+
*/
58+
public function deleteForPlugin(Plugin $plugin): bool
59+
{
60+
return $this->deleteByUrl($plugin->og_image);
5361
}
5462

5563
/**
56-
* Get the file path for storing the OG image.
64+
* Render an OG image to the public disk and return its public URL.
5765
*/
58-
protected function getImagePath(Article $article): string
66+
protected function render(string $path, string $title, string $description, string $url, ?Layout $layout = null): string
5967
{
60-
return Storage::disk('public')->path("og-images/{$article->slug}.png");
68+
$fullPath = Storage::disk('public')->path($path);
69+
70+
$directory = dirname($fullPath);
71+
if (! is_dir($directory)) {
72+
mkdir($directory, 0755, true);
73+
}
74+
75+
(new Image)
76+
->layout($layout ?? new NativePhpLayout)
77+
->title($title)
78+
->description($description)
79+
->backgroundColor('#ffffff')
80+
->titleColor('#141624')
81+
->descriptionColor('#141624')
82+
->border(BorderPosition::All, new Color(80, 91, 147), 5)
83+
->watermark(public_path('logo.svg'))
84+
->url($url)
85+
->save($fullPath);
86+
87+
return Storage::disk('public')->url($path);
6188
}
6289

6390
/**
64-
* Get the public URL for the OG image.
91+
* Delete a previously generated OG image given its public URL.
6592
*/
66-
protected function getImageUrl(Article $article): string
93+
protected function deleteByUrl(?string $ogImageUrl): bool
6794
{
68-
return Storage::disk('public')->url("og-images/{$article->slug}.png");
95+
if (! $ogImageUrl) {
96+
return false;
97+
}
98+
99+
$path = str_replace('/storage/', '', parse_url($ogImageUrl, PHP_URL_PATH));
100+
101+
return Storage::disk('public')->delete($path);
69102
}
70103
}

app/Services/PillBox.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use Intervention\Image\Geometry\Factories\CircleFactory;
6+
use Intervention\Image\Geometry\Factories\RectangleFactory;
7+
use Intervention\Image\Geometry\Point;
8+
use Intervention\Image\Geometry\Rectangle;
9+
use Intervention\Image\Interfaces\SizeInterface;
10+
use SimonHamp\TheOg\Layout\TextBox;
11+
12+
/**
13+
* A "pill" feature: text centered on a stadium-shaped (fully rounded) background.
14+
*
15+
* Intervention has no rounded-rectangle primitive, so the pill is composed from a
16+
* centre rectangle plus a circle at each end.
17+
*/
18+
class PillBox extends TextBox
19+
{
20+
protected string $backgroundColor = '#eef1f6';
21+
22+
protected int $paddingX = 24;
23+
24+
protected int $pillHeight = 56;
25+
26+
public function backgroundColor(string $color): self
27+
{
28+
$this->backgroundColor = $color;
29+
30+
return $this;
31+
}
32+
33+
public function paddingX(int $padding): self
34+
{
35+
$this->paddingX = $padding;
36+
37+
return $this;
38+
}
39+
40+
public function pillHeight(int $height): self
41+
{
42+
$this->pillHeight = $height;
43+
44+
return $this;
45+
}
46+
47+
public function dimensions(): SizeInterface
48+
{
49+
return new Rectangle($this->textWidth() + ($this->paddingX * 2), $this->pillHeight);
50+
}
51+
52+
public function render(): void
53+
{
54+
$position = $this->calculatePosition();
55+
$width = $this->dimensions()->width();
56+
$height = $this->dimensions()->height();
57+
$radius = intval(floor($height / 2));
58+
$x = $position->x();
59+
$y = $position->y();
60+
61+
$this->canvas()->drawRectangle($x + $radius, $y, function (RectangleFactory $rectangle) use ($width, $height, $radius): void {
62+
$rectangle->size(max(0, $width - ($radius * 2)), $height);
63+
$rectangle->background($this->backgroundColor);
64+
});
65+
66+
foreach ([$x + $radius, $x + $width - $radius] as $centerX) {
67+
$this->canvas()->drawCircle($centerX, $y + $radius, function (CircleFactory $circle) use ($height): void {
68+
$circle->diameter($height);
69+
$circle->background($this->backgroundColor);
70+
});
71+
}
72+
73+
$modifier = $this->modifier($this->text);
74+
$modifier->position = new Point($x + intval(floor($width / 2)), $y + intval(floor($height / 2)));
75+
76+
$this->canvas()->modify($modifier);
77+
}
78+
79+
protected function textWidth(): int
80+
{
81+
return $this->canvas()->driver()
82+
->fontProcessor()
83+
->boxSize($this->text, $this->interventionFontInstance())
84+
->width();
85+
}
86+
}

0 commit comments

Comments
 (0)