master
1import { load } from 'cheerio';
2import type { CheerioAPI } from 'cheerio';
3
4export interface LinkPreviewData {
5 title: string;
6 description: string;
7 siteName: string;
8 image?: string | null;
9 favicon?: string | null;
10 url: string;
11 fetchedAt: string;
12}
13
14interface LinkPreviewFallback {
15 title?: string;
16 description?: string;
17 siteName?: string;
18 image?: string | null;
19 favicon?: string | null;
20}
21
22const previewCache = new Map<string, Promise<LinkPreviewData>>();
23
24function makeAbsolute(resource: string | undefined | null, baseUrl: string): string | null {
25 if (!resource) return null;
26 try {
27 return new URL(resource, baseUrl).href;
28 } catch (error) {
29 console.warn(`Failed to resolve resource URL for ${resource}`, error);
30 return null;
31 }
32}
33
34type PartialPreview = {
35 title?: string | null;
36 description?: string | null;
37 siteName?: string | null;
38 image?: string | null;
39 favicon?: string | null;
40};
41
42function extractMeta($: CheerioAPI, selector: string): string | null {
43 const content = $(selector).attr('content');
44 if (!content) return null;
45 return content.trim() || null;
46}
47
48function createPreviewData(
49 url: string,
50 partial: PartialPreview,
51 fallback: LinkPreviewFallback
52): LinkPreviewData {
53 const target = new URL(url);
54
55 const title = partial.title?.trim() || fallback.title?.trim() || target.hostname;
56 const description = partial.description?.trim() || fallback.description?.trim() || '';
57 const siteName = partial.siteName?.trim() || fallback.siteName?.trim() || target.hostname;
58 const image = partial.image ?? fallback.image ?? null;
59 const favicon = partial.favicon ?? fallback.favicon ?? null;
60
61 return {
62 title,
63 description,
64 siteName,
65 image,
66 favicon,
67 url,
68 fetchedAt: new Date().toISOString(),
69 };
70}
71
72async function fetchAndParse(
73 url: string,
74 fallback: LinkPreviewFallback
75): Promise<LinkPreviewData> {
76 const controller = new AbortController();
77 const timeout = setTimeout(() => controller.abort(), 10_000);
78
79 try {
80 const response = await fetch(url, {
81 headers: {
82 'User-Agent': 'Astral-Halo-LinkPreview/1.0 (+https://astral-halo.netlify.app)',
83 Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
84 },
85 signal: controller.signal,
86 });
87
88 if (!response.ok) {
89 throw new Error(
90 `Failed to fetch preview for ${url}: ${response.status} ${response.statusText}`
91 );
92 }
93
94 const html = await response.text();
95 const $ = load(html);
96
97 const title =
98 extractMeta($, 'meta[property="og:title"]') ||
99 extractMeta($, 'meta[name="twitter:title"]') ||
100 $('title').first().text().trim() ||
101 null;
102
103 const description =
104 extractMeta($, 'meta[property="og:description"]') ||
105 extractMeta($, 'meta[name="description"]') ||
106 extractMeta($, 'meta[name="twitter:description"]') ||
107 null;
108
109 const image = makeAbsolute(
110 extractMeta($, 'meta[property="og:image"]') ||
111 extractMeta($, 'meta[name="twitter:image"]') ||
112 $('img[src]').first().attr('src'),
113 url
114 );
115
116 const siteName =
117 extractMeta($, 'meta[property="og:site_name"]') ||
118 $('meta[name="application-name"]').attr('content') ||
119 $('meta[name="author"]').attr('content') ||
120 null;
121
122 const faviconLink = $(
123 'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
124 )
125 .map((_, el) => $(el).attr('href')?.trim())
126 .get()
127 .find(Boolean);
128
129 const favicon = makeAbsolute(faviconLink, url);
130
131 return createPreviewData(url, { title, description, image, siteName, favicon }, fallback);
132 } catch (error) {
133 console.warn(`[LinkPreview] Failed to fetch ${url}:`, error);
134 return createPreviewData(url, {}, fallback);
135 } finally {
136 clearTimeout(timeout);
137 }
138}
139
140export async function getLinkPreview(
141 url: string,
142 fallback: LinkPreviewFallback = {}
143): Promise<LinkPreviewData> {
144 if (!previewCache.has(url)) {
145 const previewPromise = fetchAndParse(url, fallback).catch((error) => {
146 previewCache.delete(url);
147 throw error;
148 });
149 previewCache.set(url, previewPromise);
150 }
151
152 try {
153 return await previewCache.get(url)!;
154 } catch {
155 return createPreviewData(url, {}, fallback);
156 }
157}