diff --git a/app/Console/Commands/MirrorToolLogos.php b/app/Console/Commands/MirrorToolLogos.php
index ae6342c..2202ce7 100644
--- a/app/Console/Commands/MirrorToolLogos.php
+++ b/app/Console/Commands/MirrorToolLogos.php
@@ -9,11 +9,11 @@ class MirrorToolLogos extends Command
{
protected $signature = 'tools:mirror-logos';
- protected $description = 'Queue jobs to download externally-hosted tool logos and serve them from local storage';
+ protected $description = 'Queue jobs to resolve and download tool logos (external, missing, or broken) and serve them from local storage';
public function handle(): int
{
- $tools = Tool::query()->whereNotNull('logo_url')->get()->reject->hasLocalLogo();
+ $tools = Tool::query()->get()->reject->hasLocalLogo();
$tools->each->queueLogoMirrorIfNeeded();
diff --git a/app/Jobs/MirrorToolLogo.php b/app/Jobs/MirrorToolLogo.php
index fba1d3b..0021d81 100644
--- a/app/Jobs/MirrorToolLogo.php
+++ b/app/Jobs/MirrorToolLogo.php
@@ -3,9 +3,11 @@
namespace App\Jobs;
use App\Models\Tool;
+use App\Support\FaviconResolver;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Http\Client\Response;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
@@ -16,20 +18,25 @@ class MirrorToolLogo implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public function __construct(public Tool $tool, public string $sourceUrl) {}
+ public function __construct(public Tool $tool) {}
- public function handle(): void
+ public function handle(FaviconResolver $resolver): void
{
- // The tool may have been re-saved with a different logo_url (or one
- // already mirrored) since this job was queued; only proceed if it's
- // still pointing at the URL we were asked to mirror.
- if ($this->tool->fresh()?->logo_url !== $this->sourceUrl) {
+ $tool = $this->tool->fresh();
+
+ // The tool may have been deleted, or already mirrored by an earlier
+ // run of this same job, since this was queued.
+ if (! $tool || $tool->hasLocalLogo()) {
return;
}
- $response = Http::timeout(10)->get($this->sourceUrl);
+ // logo_url is a hint, not a guarantee -- it's often a guessed
+ // `{domain}/favicon.ico` that 404s or isn't actually an image.
+ // Fall back to properly resolving the site's real favicon.
+ $response = ($tool->logo_url ? $this->fetchImage($tool->logo_url) : null)
+ ?? $this->fetchImage($resolver->resolve($tool->url));
- if (! $response->successful() || ! str_starts_with((string) $response->header('Content-Type'), 'image/')) {
+ if (! $response) {
return;
}
@@ -38,13 +45,30 @@ class MirrorToolLogo implements ShouldQueue
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
- default => 'svg' === pathinfo(parse_url($this->sourceUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ? 'svg' : 'png',
+ default => 'png',
};
- $path = "tools/{$this->tool->slug}-".Str::random(8).".{$extension}";
+ $path = "tools/{$tool->slug}-".Str::random(8).".{$extension}";
Storage::disk('public')->put($path, $response->body());
- $this->tool->update(['logo_url' => Storage::disk('public')->url($path)]);
+ $tool->update(['logo_url' => Storage::disk('public')->url($path)]);
+ }
+
+ private function fetchImage(?string $url): ?Response
+ {
+ if (! $url) {
+ return null;
+ }
+
+ try {
+ $response = Http::timeout(10)->get($url);
+ } catch (\Throwable) {
+ return null;
+ }
+
+ return $response->successful() && str_starts_with((string) $response->header('Content-Type'), 'image/')
+ ? $response
+ : null;
}
}
diff --git a/app/Models/Post.php b/app/Models/Post.php
index 06caba8..b11a595 100644
--- a/app/Models/Post.php
+++ b/app/Models/Post.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PostStatus;
+use App\Support\ToolLinker;
use Database\Factories\PostFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\RouteKey;
@@ -88,12 +89,14 @@ class Post extends Model
// The page template already renders the post title as an
. If the
// Markdown body also opens with a top-level heading repeating that
// title, drop it so the page doesn't ship two identical
s.
- return preg_replace_callback(
+ $html = preg_replace_callback(
'/^\s*
(.*?)<\/h1>/is',
fn ($matches) => trim(strip_tags($matches[1])) === trim($this->title) ? '' : $matches[0],
$html,
limit: 1
);
+
+ return (new ToolLinker)->link($html);
});
}
diff --git a/app/Models/Tool.php b/app/Models/Tool.php
index 71608db..42415f8 100644
--- a/app/Models/Tool.php
+++ b/app/Models/Tool.php
@@ -60,8 +60,8 @@ class Tool extends Model
public function queueLogoMirrorIfNeeded(): void
{
- if ($this->logo_url && ! $this->hasLocalLogo()) {
- MirrorToolLogo::dispatch($this, $this->logo_url);
+ if (! $this->hasLocalLogo()) {
+ MirrorToolLogo::dispatch($this);
}
}
diff --git a/app/Support/FaviconResolver.php b/app/Support/FaviconResolver.php
new file mode 100644
index 0000000..b6e7e8f
--- /dev/null
+++ b/app/Support/FaviconResolver.php
@@ -0,0 +1,107 @@
+
+ * tag. This tries progressively more reliable sources before giving up.
+ */
+class FaviconResolver
+{
+ public function resolve(string $siteUrl): ?string
+ {
+ return $this->fromWellKnownPath($siteUrl)
+ ?? $this->fromHtmlLinkTag($siteUrl)
+ ?? $this->fromGoogleFallback($siteUrl);
+ }
+
+ private function fromWellKnownPath(string $siteUrl): ?string
+ {
+ $candidate = rtrim($siteUrl, '/').'/favicon.ico';
+
+ return $this->isImage($candidate) ? $candidate : null;
+ }
+
+ private function fromHtmlLinkTag(string $siteUrl): ?string
+ {
+ $response = $this->safeGet($siteUrl);
+
+ if (! $response || ! $response->successful()) {
+ return null;
+ }
+
+ if (! preg_match_all(
+ '/]*rel=["\'](?:shortcut icon|icon|apple-touch-icon)["\'][^>]*>/i',
+ $response->body(),
+ $tags
+ )) {
+ return null;
+ }
+
+ foreach ($tags[0] as $tag) {
+ if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $hrefMatch)) {
+ continue;
+ }
+
+ $absolute = $this->toAbsoluteUrl($hrefMatch[1], $siteUrl);
+
+ if ($absolute && $this->isImage($absolute)) {
+ return $absolute;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Google's favicon service resolves almost anything (it does its own
+ * multi-source lookup), making it a reliable last resort rather than
+ * leaving the tool with no logo at all.
+ */
+ private function fromGoogleFallback(string $siteUrl): string
+ {
+ return 'https://www.google.com/s2/favicons?sz=128&domain_url='.urlencode($siteUrl);
+ }
+
+ private function toAbsoluteUrl(string $href, string $base): ?string
+ {
+ if (Str::startsWith($href, ['http://', 'https://'])) {
+ return $href;
+ }
+
+ $parsed = parse_url($base);
+
+ if (! $parsed || ! isset($parsed['scheme'], $parsed['host'])) {
+ return null;
+ }
+
+ if (Str::startsWith($href, '//')) {
+ return "{$parsed['scheme']}:{$href}";
+ }
+
+ return "{$parsed['scheme']}://{$parsed['host']}/".ltrim($href, '/');
+ }
+
+ private function isImage(string $url): bool
+ {
+ $response = $this->safeGet($url);
+
+ return (bool) $response?->successful()
+ && str_starts_with((string) $response->header('Content-Type'), 'image/');
+ }
+
+ private function safeGet(string $url): ?Response
+ {
+ try {
+ return Http::timeout(6)->get($url);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/app/Support/ToolLinker.php b/app/Support/ToolLinker.php
new file mode 100644
index 0000000..af056b7
--- /dev/null
+++ b/app/Support/ToolLinker.php
@@ -0,0 +1,111 @@
+get(['id', 'name', 'slug'])
+ ->sortByDesc(fn (Tool $tool) => mb_strlen($tool->name))
+ ->values();
+
+ if ($tools->isEmpty()) {
+ return $html;
+ }
+
+ $document = new DOMDocument();
+ libxml_use_internal_errors(true);
+ $document->loadHTML(
+ '
'.$html.'
',
+ LIBXML_NOERROR | LIBXML_NOWARNING
+ );
+ libxml_clear_errors();
+
+ $xpath = new DOMXPath($document);
+ $textNodes = $xpath->query(
+ '//text()[not(ancestor::a) and not(ancestor::code) and not(ancestor::pre) '.
+ 'and not(ancestor::script) and not(ancestor::style)]'
+ );
+
+ $alreadyLinked = [];
+
+ foreach ($textNodes as $node) {
+ /** @var DOMText $node */
+ foreach ($tools as $tool) {
+ if (isset($alreadyLinked[$tool->id])) {
+ continue;
+ }
+
+ $pattern = '/\b'.preg_quote($tool->name, '/').'\b/ui';
+
+ if (! preg_match($pattern, $node->wholeText, $match, PREG_OFFSET_CAPTURE)) {
+ continue;
+ }
+
+ $this->wrapMatchInLink($document, $node, $match[0], $tool->slug);
+ $alreadyLinked[$tool->id] = true;
+
+ // The node has been split and detached from the document;
+ // move on to the next original text node.
+ break;
+ }
+ }
+
+ $wrapper = $document->getElementsByTagName('div')->item(0);
+ $result = '';
+
+ foreach (iterator_to_array($wrapper->childNodes) as $child) {
+ $result .= $document->saveHTML($child);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array{0: string, 1: int} $match [matched text, byte offset] from PREG_OFFSET_CAPTURE
+ */
+ private function wrapMatchInLink(DOMDocument $document, DOMText $node, array $match, string $slug): void
+ {
+ [$matchedText, $offset] = $match;
+
+ // PREG_OFFSET_CAPTURE offsets are byte offsets, so slice with
+ // substr() (byte-based), not mb_substr() -- the offsets are still
+ // valid UTF-8 boundaries since the /u match itself respects them.
+ $before = substr($node->wholeText, 0, $offset);
+ $after = substr($node->wholeText, $offset + strlen($matchedText));
+
+ $link = $document->createElement('a');
+ $link->setAttribute('href', "/tools/{$slug}");
+ $link->appendChild($document->createTextNode($matchedText));
+
+ $parent = $node->parentNode;
+
+ if ($before !== '') {
+ $parent->insertBefore($document->createTextNode($before), $node);
+ }
+
+ $parent->insertBefore($link, $node);
+
+ if ($after !== '') {
+ $parent->insertBefore($document->createTextNode($after), $node);
+ }
+
+ $parent->removeChild($node);
+ }
+}