diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index 6268a82f..b9c01b47 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { ScoreBadge } from "@/components/score-badge";
import { FontSparkline } from "@/components/font-sparkline";
+import { ProjectLogo } from "@/components/project-logo";
import { backfillProjectLogo } from "@/app/actions/createProject";
import { getOrCreateDefaultOrg, isOrgWideRole, listUserOrgs, missingOrgSchema } from "@/lib/orgs";
import { listOrgTeam } from "@/app/actions/org-members";
@@ -196,6 +197,7 @@ export default async function DashboardPage({
{p.name}
@@ -315,39 +317,6 @@ export default async function DashboardPage({
);
}
-function ProjectLogo({
- url,
- name,
-}: {
- url: string | null;
- name: string;
-}) {
- const letter = (name || "?").trim().charAt(0).toUpperCase();
- // 40px square; rounded corners; one-letter fallback when the site
- // either has no detectable logo or backfill hasn't run yet.
- if (!url) {
- return (
-
- {letter}
-
- );
- }
- return (
- // eslint-disable-next-line @next/next/no-img-element
-

- );
-}
-
type LatestPost = { url: string; publishedAt: string };
type TrafficPoint = { day: string; count: number };
diff --git a/app/actions/createProject.ts b/app/actions/createProject.ts
index 4f798a4b..ef99b651 100644
--- a/app/actions/createProject.ts
+++ b/app/actions/createProject.ts
@@ -101,6 +101,40 @@ export async function backfillProjectLogo(
}
}
+// Re-run logo discovery for a project whose stored logo is broken (the
+// dashboard's
![]()
onError triggers this). Unlike backfillProjectLogo it
+// overwrites an existing logo_url, and clears it when nothing valid is found so
+// the tile falls back to a letter avatar instead of a broken image.
+export async function refetchProjectLogo(
+ projectId: string,
+): Promise<{ ok: boolean }> {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false };
+
+ const { data: project } = await supabase
+ .from("projects")
+ .select("id, url")
+ .eq("id", projectId)
+ .maybeSingle();
+ if (!project?.url) return { ok: false };
+
+ try {
+ const logoUrl = await discoverLogoUrl(project.url);
+ await serviceClient()
+ .from("projects")
+ .update({ logo_url: logoUrl })
+ .eq("id", projectId);
+ revalidatePath("/dashboard");
+ return { ok: true };
+ } catch (err) {
+ console.warn("[discoverLogo] refetch failed for", projectId, err);
+ return { ok: false };
+ }
+}
+
export async function updateSchedule(input: {
projectId: string;
schedule: "off" | "daily" | "weekly" | "monthly";
diff --git a/components/project-logo.tsx b/components/project-logo.tsx
new file mode 100644
index 00000000..b3542e9f
--- /dev/null
+++ b/components/project-logo.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { refetchProjectLogo } from "@/app/actions/createProject";
+
+// Project tile logo. Shows the discovered logo; on a broken image it falls back
+// to a letter avatar immediately AND fires a one-shot re-discovery so a stale /
+// 404'ing logo_url self-heals on the next render.
+export function ProjectLogo({
+ url,
+ name,
+ projectId,
+}: {
+ url: string | null;
+ name: string;
+ projectId?: string;
+}) {
+ const letter = (name || "?").trim().charAt(0).toUpperCase();
+ const [broken, setBroken] = useState(false);
+ const refetched = useRef(false);
+
+ if (!url || broken) {
+ return (
+
+ {letter}
+
+ );
+ }
+
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+

{
+ setBroken(true);
+ // Re-discover once; the server clears/updates logo_url so the next load
+ // shows a working logo (or a clean letter avatar).
+ if (projectId && !refetched.current) {
+ refetched.current = true;
+ void refetchProjectLogo(projectId);
+ }
+ }}
+ className="h-10 w-10 shrink-0 rounded-md border border-[var(--color-border)] bg-white object-contain p-1"
+ />
+ );
+}
diff --git a/lib/discoverLogo.ts b/lib/discoverLogo.ts
index 0c96d003..aa6b7d48 100644
--- a/lib/discoverLogo.ts
+++ b/lib/discoverLogo.ts
@@ -1,23 +1,31 @@
import * as cheerio from "cheerio";
-// Walk a page's and pick the best-looking logo URL. Returns
-// null if we can't find anything plausible (offline, parse error,
-// site explicitly has no icons).
+// Walk a page's (and its web-app manifest) and pick the best logo URL
+// that ACTUALLY RESOLVES to an image. Returns null if nothing plausible loads
+// (offline, parse error, site has no usable icons) — the dashboard then shows a
+// letter avatar instead of a broken
![]()
.
//
-// Preference order — this is what the dashboard ends up showing:
-// 1. apple-touch-icon (largest size attribute wins)
-// 2.
with type=image/svg+xml
-// 3.
with the largest size attribute
-// 4. og:image (good for sites that don't ship a proper favicon set)
-// 5. twitter:image
-// 6. /favicon.ico — last-ditch, almost every site has this
+// Preference order:
+// 1. web-app-manifest icons (largest; often the real brand logo)
+// 2. apple-touch-icon (largest size wins)
+// 3.
svg, then largest raster; mask-icon
+// 4. og:image / twitter:image (sites with no proper favicon set)
+// 5. /favicon.ico — last-ditch
//
-// We trust HTTP redirects on the page fetch (so http://foo -> https://foo
-// works) but cap response time at FETCH_TIMEOUT_MS so a slow site can't
-// block a project create or dashboard render.
+// Every candidate is verified with a real request (2xx + image content-type)
+// before we accept it, so we never persist a 404'ing URL. We trust redirects
+// on the page fetch but cap response time so a slow site can't block a project
+// create or dashboard render.
const FETCH_TIMEOUT_MS = 4000;
+const VALIDATE_TIMEOUT_MS = 3000;
const MAX_BYTES = 1_000_000; // 1 MB of HTML is way more than enough for
+const MAX_VALIDATIONS = 6; // cap image HEAD/GET checks so we stay snappy
+
+const UA =
+ "Mozilla/5.0 (compatible; CrawlProofLogoBot/1.0; +https://crawlproof.com)";
+
+const IMAGE_EXT_RE = /\.(png|jpe?g|svg|ico|webp|gif|avif)(\?|#|$)/i;
export async function discoverLogoUrl(siteUrl: string): Promise
{
let parsed: URL;
@@ -30,54 +38,134 @@ export async function discoverLogoUrl(siteUrl: string): Promise {
const html = await fetchHead(siteUrl);
if (!html) {
- // Even without the HTML, /favicon.ico is a defensible guess.
- return `${parsed.origin}/favicon.ico`;
+ // Even without the HTML, /favicon.ico is a defensible guess — if it loads.
+ const ico = `${parsed.origin}/favicon.ico`;
+ return (await isImageUrl(ico)) ? ico : null;
}
const $ = cheerio.load(html);
+ // Respect for resolving relative icon URLs.
+ const baseHref = $("base[href]").attr("href");
+ const base = baseHref ? safeUrl(baseHref, parsed) ?? parsed : parsed;
const candidates: { url: string; weight: number }[] = [];
+ const add = (href: string | undefined, weight: number) => {
+ if (!href) return;
+ const abs = absolutize(href, base);
+ if (abs) candidates.push({ url: abs, weight });
+ };
$('link[rel*="apple-touch-icon" i]').each((_i, el) => {
- const href = $(el).attr("href");
- if (!href) return;
- const size = parseSizes($(el).attr("sizes"));
- // Apple icons are usually the prettiest variant; rank by size.
- candidates.push({ url: href, weight: 1000 + size });
+ add($(el).attr("href"), 900 + parseSizes($(el).attr("sizes")));
});
$('link[rel*="icon" i]').each((_i, el) => {
const $el = $(el);
const rel = ($el.attr("rel") ?? "").toLowerCase();
- if (rel.includes("apple-touch-icon")) return; // already collected above
- const href = $el.attr("href");
- if (!href) return;
+ if (rel.includes("apple-touch-icon")) return; // collected above
const type = ($el.attr("type") ?? "").toLowerCase();
const size = parseSizes($el.attr("sizes"));
// SVG icons scale to any DOM size — prefer them over raster.
- const base = type === "image/svg+xml" ? 800 : 400;
- candidates.push({ url: href, weight: base + size });
+ const weight = type === "image/svg+xml" ? 800 + size : 400 + size;
+ add($el.attr("href"), weight);
});
- const og = $('meta[property="og:image"]').attr("content");
- if (og) candidates.push({ url: og, weight: 200 });
- const tw = $('meta[name="twitter:image"]').attr("content");
- if (tw) candidates.push({ url: tw, weight: 150 });
+ // Safari pinned-tab / mask icon — usually a clean monochrome SVG logo.
+ $('link[rel*="mask-icon" i]').each((_i, el) => {
+ add($(el).attr("href"), 700);
+ });
+
+ add($('meta[property="og:image"]').attr("content"), 200);
+ add($('meta[name="twitter:image"]').attr("content"), 150);
+
+ // Web app manifest icons — often the highest-quality brand mark.
+ const manifestHref = $('link[rel="manifest" i]').attr("href");
+ if (manifestHref) {
+ const manifestUrl = absolutize(manifestHref, base);
+ if (manifestUrl) {
+ for (const icon of await fetchManifestIcons(manifestUrl)) {
+ // Rank just below apple-touch so a big manifest icon can win.
+ add(icon.src, 950 + icon.size);
+ }
+ }
+ }
- candidates.push({ url: "/favicon.ico", weight: 1 });
+ add(`${parsed.origin}/favicon.ico`, 1);
- candidates.sort((a, b) => b.weight - a.weight);
+ // Highest weight first, de-duped, then return the first that actually loads.
+ const seen = new Set();
+ const ordered = candidates
+ .sort((a, b) => b.weight - a.weight)
+ .filter((c) => (seen.has(c.url) ? false : (seen.add(c.url), true)));
- for (const c of candidates) {
- const abs = absolutize(c.url, parsed);
- if (abs) return abs;
+ let checked = 0;
+ for (const c of ordered) {
+ if (checked >= MAX_VALIDATIONS) break;
+ checked++;
+ if (await isImageUrl(c.url)) return c.url;
}
return null;
}
+// Resolve a manifest URL to its icon list ({src, size}), largest first-ish.
+async function fetchManifestIcons(
+ manifestUrl: string,
+): Promise<{ src: string; size: number }[]> {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), VALIDATE_TIMEOUT_MS);
+ try {
+ const res = await fetch(manifestUrl, {
+ redirect: "follow",
+ signal: ctrl.signal,
+ headers: { "User-Agent": UA, Accept: "application/manifest+json,application/json" },
+ });
+ if (!res.ok) return [];
+ const json = (await res.json()) as {
+ icons?: Array<{ src?: string; sizes?: string }>;
+ };
+ const manifestBase = new URL(manifestUrl);
+ const out: { src: string; size: number }[] = [];
+ for (const icon of json.icons ?? []) {
+ const abs = icon.src ? safeUrl(icon.src, manifestBase) : null;
+ if (abs) out.push({ src: abs.toString(), size: parseSizes(icon.sizes) });
+ }
+ return out;
+ } catch {
+ return [];
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+// Verify a URL responds 2xx with an image content-type (or an image extension
+// when a server sends a generic type). Cancels the body so we don't download
+// the whole image.
+async function isImageUrl(url: string): Promise {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), VALIDATE_TIMEOUT_MS);
+ try {
+ const res = await fetch(url, {
+ method: "GET",
+ redirect: "follow",
+ signal: ctrl.signal,
+ headers: { "User-Agent": UA, Accept: "image/*,*/*" },
+ });
+ void res.body?.cancel().catch(() => {});
+ if (!res.ok) return false;
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
+ if (ct.startsWith("image/")) return true;
+ // Some CDNs mislabel; trust a clear image extension on a 2xx.
+ return IMAGE_EXT_RE.test(new URL(res.url || url).pathname);
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
function parseSizes(attr: string | undefined): number {
if (!attr) return 0;
- // sizes="180x180" or sizes="32x32 16x16" — pick the largest dimension.
+ if (/any/i.test(attr)) return 512; // scalable ("any") — treat as large
let max = 0;
for (const tok of attr.split(/\s+/)) {
const m = tok.match(/^(\d+)x(\d+)$/i);
@@ -89,14 +177,18 @@ function parseSizes(attr: string | undefined): number {
return max;
}
-function absolutize(href: string, base: URL): string | null {
+function safeUrl(href: string, base: URL): URL | null {
try {
- return new URL(href, base).toString();
+ return new URL(href, base);
} catch {
return null;
}
}
+function absolutize(href: string, base: URL): string | null {
+ return safeUrl(href, base)?.toString() ?? null;
+}
+
async function fetchHead(url: string): Promise {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
diff --git a/tests/discover-logo.test.ts b/tests/discover-logo.test.ts
new file mode 100644
index 00000000..1482119f
--- /dev/null
+++ b/tests/discover-logo.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { discoverLogoUrl } from "@/lib/discoverLogo";
+
+const PAGE = "https://example.com/";
+
+// Build a fake fetch keyed by URL. `images` maps an absolute image URL to the
+// status/content-type it should return; anything else 404s.
+function stubFetch(html: string, images: Record) {
+ vi.stubGlobal("fetch", async (input: unknown) => {
+ const url = String(input);
+ if (url === PAGE) {
+ return {
+ ok: true,
+ status: 200,
+ url,
+ headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null) },
+ body: null, // fetchHead falls back to res.text()
+ text: async () => html,
+ } as unknown as Response;
+ }
+ const img = images[url];
+ if (img) {
+ return {
+ ok: img.status < 400,
+ status: img.status,
+ url,
+ headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? img.ct ?? "image/png" : null) },
+ body: { cancel: async () => {} },
+ } as unknown as Response;
+ }
+ return { ok: false, status: 404, url, headers: { get: () => null }, body: { cancel: async () => {} } } as unknown as Response;
+ });
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("discoverLogoUrl", () => {
+ it("skips a broken candidate and returns the next one that actually loads", async () => {
+ const html = `
+
+
+ `;
+ // Apple icon (highest weight) 404s; favicon.ico is valid.
+ stubFetch(html, {
+ "https://example.com/apple.png": { status: 404 },
+ "https://example.com/favicon.ico": { status: 200, ct: "image/x-icon" },
+ });
+ expect(await discoverLogoUrl(PAGE)).toBe("https://example.com/favicon.ico");
+ });
+
+ it("prefers the highest-weight icon when it loads", async () => {
+ const html = `
+
+
+ `;
+ stubFetch(html, {
+ "https://example.com/apple.png": { status: 200, ct: "image/png" },
+ "https://example.com/favicon.ico": { status: 200, ct: "image/x-icon" },
+ });
+ expect(await discoverLogoUrl(PAGE)).toBe("https://example.com/apple.png");
+ });
+
+ it("returns null when no candidate resolves to an image", async () => {
+ const html = ``;
+ stubFetch(html, {}); // everything 404s, including /favicon.ico
+ expect(await discoverLogoUrl(PAGE)).toBeNull();
+ });
+
+ it("accepts an image served with a generic content-type by extension", async () => {
+ const html = ``;
+ stubFetch(html, {
+ "https://example.com/logo.svg": { status: 200, ct: "application/octet-stream" },
+ "https://example.com/favicon.ico": { status: 404 },
+ });
+ expect(await discoverLogoUrl(PAGE)).toBe("https://example.com/logo.svg");
+ });
+});