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}