| @@ -1,15 +1,65 @@ |
| 1 | import { BoxIcon } from "lucide-react"; | 1 | import { BoxIcon } from "lucide-react"; |
| | 2 | import { useState } from "react"; |
| | 3 | import { getDomain } from "tldts"; |
| 2 | | 4 | |
| 3 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 4 | // SourceFavicon — renders a neutral local source icon. | 6 | // SourceFavicon — renders the source site's own public favicon. |
| 5 | // Do not fetch third-party favicon services here; source URLs may be private. | 7 | // Do not fetch third-party favicon services here; source URLs may be private. |
| 6 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 7 | | 9 | |
| 8 | export function SourceFavicon({ size = 16 }: { url?: string; size?: number }) { | 10 | const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/; |
| | 11 | |
| | 12 | const isIpHostname = (hostname: string): boolean => |
| | 13 | IPV4_PATTERN.test(hostname) || hostname.includes(":"); |
| | 14 | |
| | 15 | export function sourceFaviconUrl(url: string | undefined, size: number): string | null { |
| | 16 | if (!url) return null; |
| | 17 | if (!URL.canParse(url)) return null; |
| | 18 | |
| | 19 | const parsed = new URL(url); |
| | 20 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; |
| | 21 | |
| | 22 | const hostname = parsed.hostname.toLowerCase(); |
| | 23 | if ( |
| | 24 | hostname === "localhost" || |
| | 25 | hostname.endsWith(".localhost") || |
| | 26 | hostname.endsWith(".local") || |
| | 27 | isIpHostname(hostname) |
| | 28 | ) { |
| | 29 | return null; |
| | 30 | } |
| | 31 | |
| | 32 | const domain = getDomain(hostname); |
| | 33 | if (!domain) return null; |
| | 34 | |
| | 35 | const favicon = new URL(`https://${domain}/favicon.ico`); |
| | 36 | favicon.searchParams.set("sz", String(size * 2)); |
| | 37 | return favicon.toString(); |
| | 38 | } |
| | 39 | |
| | 40 | export function SourceFavicon({ url, size = 16 }: { url?: string; size?: number }) { |
| | 41 | const [failed, setFailed] = useState(false); |
| | 42 | const src = failed ? null : sourceFaviconUrl(url, size); |
| | 43 | |
| | 44 | if (!src) { |
| | 45 | return ( |
| | 46 | <BoxIcon |
| | 47 | aria-hidden |
| | 48 | className="shrink-0 text-muted-foreground" |
| | 49 | style={{ width: size, height: size }} |
| | 50 | /> |
| | 51 | ); |
| | 52 | } |
| | 53 | |
| 9 | return ( | 54 | return ( |
| 10 | <BoxIcon | 55 | <img |
| 11 | aria-hidden | 56 | src={src} |
| 12 | className="shrink-0 text-muted-foreground" | 57 | alt="" |
| | 58 | width={size} |
| | 59 | height={size} |
| | 60 | loading="lazy" |
| | 61 | onError={() => setFailed(true)} |
| | 62 | className="shrink-0 rounded-sm" |
| 13 | style={{ width: size, height: size }} | 63 | style={{ width: size, height: size }} |
| 14 | /> | 64 | /> |
| 15 | ); | 65 | ); |