"use client" import React, { useState, useEffect, Component, ReactNode } from 'react' import Image from '../../embed-shims/next-image' import { useImageEdgeColor } from '../../hooks' /** * Open-Graph metadata returned by the consumer's scrape endpoint. * * The shape MUST match the JSON the OG endpoint serves at `ogEndpointPath`. * The hub's `/api/og-scraper` returns exactly these fields — embedders * with a different endpoint must return the same shape (or adapt at the * route boundary). Keeps the consumer surface trivial: one URL → one card. */ export interface OGData { title: string description: string image: string originalImage?: string url: string siteName: string type: string favicon: string } interface ErrorBoundaryProps { children: ReactNode fallback: ReactNode } interface ErrorBoundaryState { hasError: boolean } /** * Tiny error boundary tailored for OG link previews — caught errors quietly * fall back to the `fallback` prop (typically a plain hyperlink) so a single * broken third-party preview can't crash a whole article view. * * Named `OGLinkErrorBoundary` (not the generic `ErrorBoundary`) because the * lib already exports a separate `ErrorBoundary` from * `components/features/error-boundary.tsx`. The top-level `components/index.ts` * barrel re-exports both `./embeds` and `./features` via `export *`, so a * second `ErrorBoundary` here collides as TS2308. */ export class OGLinkErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props) this.state = { hasError: false } } static getDerivedStateFromError(): ErrorBoundaryState { return { hasError: true } } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.warn('Link preview error caught by boundary:', error, errorInfo) } render() { if (this.state.hasError) return this.props.fallback return this.props.children } } /** * Builds a placeholder image URL when the scrape returns no image. Hub passes * its own `buildOgPlaceholderUrl` (which hits `/api/og-placeholder?…&platform=`; * the route resolves the platform's brand colors server-side); other embedders * can omit the prop to disable the placeholder entirely. * * Receives the post-scrape `title` and `siteName` so the placeholder can echo * the actual card content, not a generic graphic. */ export type BuildPlaceholderUrl = ( title: string, siteName: string, ) => string | null export interface OGLinkPreviewProps { /** The external URL to preview. */ url: string /** Origin / base URL the OG endpoint is served from. Empty / undefined * means same-origin (hub-direct use). Embed contexts pass the hub's * origin here (e.g. `'https://hub.example.com'`) so the fetch hits * the hub instead of the embedder origin. * * Pattern matches lib's `useNatsDialogSubscription({apiBaseUrl})` + * `buildSuggestionUrl({apiBaseUrl})` so all embed-ready surfaces share * one configuration knob. */ apiBaseUrl?: string /** Path of the OG endpoint on the configured base. Default * `'/api/og-scraper'` matches the hub's route. Override if the * embedder serves the same `OGData` shape from a different path. */ ogEndpointPath?: string /** Optional placeholder-builder. Omit to disable the placeholder image * (the card then degrades to a favicon+title chip when no scraped image * is available). The hub injects its `buildOgPlaceholderUrl` here. */ buildPlaceholderUrl?: BuildPlaceholderUrl /** Override the scraped title (used by publication cards that already know * the title locally — e.g. a CMS-managed press link). */ fallbackTitle?: string /** Override the scraped description. */ fallbackDescription?: string /** Override the scraped image — useful when the scrape returns no image but * the embedder has a CMS-stored hero image to fall back to. */ fallbackImage?: string /** Publication / source name shown alongside the favicon (e.g. "TechCrunch"). */ publicationName?: string /** Publication logo URL shown alongside the title (defaults to favicon). */ publicationLogo?: string /** Card variant. `compact` = horizontal layout (~120px tall) suited for * in-doc placements; `default` = larger vertical layout for press / hero * positions. */ variant?: 'default' | 'compact' /** Disable the synthesized placeholder image even when `buildPlaceholderUrl` * is provided — used by the markdown renderer to keep doc cards lighter. */ enablePlaceholder?: boolean } function getDomain(urlStr: string): string { try { return new URL(urlStr).hostname.replace('www.', '') } catch { return 'External Link' } } function domainToTitle(domain: string): string { return domain.split('.')[0].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } const ExternalLinkIcon = ({ size = 16 }: { size?: number }) => ( ) const Favicon = ({ src, size = 'w-6 h-6' }: { src: string; size?: string }) => ( { (e.target as HTMLImageElement).style.display = 'none' }} /> ) /** * Rich Open-Graph link preview card with skeleton, fallback, and image-edge * background detection. * * Flow: * 1. Validate URL early (no network for malformed input, localhost, or * RFC1918 ranges — those render as plain `` tags). * 2. `GET ogEndpointPath?url=` — embedder serves the shape declared * in `OGData`. * 3. Resolve image: scraped og:image → `originalImage` fallback → `fallbackImage` * prop → `buildPlaceholderUrl(title, siteName)`. Each step has its own * error toggle so a 404 / CORS-tainted image gracefully degrades. * 4. Extract a letterbox background color from the resolved image via * `useImageEdgeColor`. Same-origin proxy is REQUIRED for cross-origin * images so the `` extraction doesn't taint. * 5. Render compact (h-[120px] horizontal) or default (vertical w/ aspect-video * hero) variant, with image-less degraded variants for each. */ export const OGLinkPreview: React.FC = ({ url, apiBaseUrl, ogEndpointPath = '/api/og-scraper', buildPlaceholderUrl, fallbackTitle, fallbackDescription, fallbackImage, publicationName, publicationLogo, variant = 'default', enablePlaceholder = true, }) => { const [ogData, setOgData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [imageError, setImageError] = useState(false) const [originalImageError, setOriginalImageError] = useState(false) const [fallbackImageError, setFallbackImageError] = useState(false) let isValidUrl = true let isLocalhost = false try { if (url && typeof url === 'string') { const urlObj = new URL(url) if (['localhost', '127.0.0.1', '0.0.0.0'].includes(urlObj.hostname) || urlObj.hostname.startsWith('192.168.') || urlObj.hostname.startsWith('10.') || urlObj.hostname.startsWith('172.')) { isLocalhost = true } } else { isValidUrl = false } } catch { isValidUrl = false } useEffect(() => { if (!isValidUrl || isLocalhost) return const fetchOGData = async () => { try { new URL(url) setLoading(true) // Compose `${base}${path}?url=…`. Empty base → relative path // (same-origin); absolute base → cross-origin embed against the hub. // Plain string concat is safer than `new URL(path, base)` because // the latter resolves `path` against the BASE's pathname when // `path` is relative, producing surprising URLs when the embedder // serves the lib from a subpath. const endpoint = `${apiBaseUrl ?? ''}${ogEndpointPath}?url=${encodeURIComponent(url)}` const response = await fetch(endpoint) if (response.ok) { const data = await response.json() if (data?.title && data.title !== 'Link Preview Unavailable') { setOgData(data) } else { setError(true) } } else { setError(true) } } catch { setError(true) } finally { setLoading(false) } } fetchOGData() }, [url, isValidUrl, isLocalhost, apiBaseUrl, ogEndpointPath]) const isCompact = variant === 'compact' const domain = getDomain(url) const effectiveData: OGData | null = ogData ?? (error ? { title: fallbackTitle || domainToTitle(domain), description: fallbackDescription || domain, image: '', url, siteName: publicationName || domain, type: 'website', favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`, } : null) // Hub-injected placeholder builder — fires only when the post-scrape image // chain is empty AND `enablePlaceholder` is true. `null` when unprovided. const placeholderImageUrl = enablePlaceholder && buildPlaceholderUrl && effectiveData?.title ? buildPlaceholderUrl(effectiveData.title, effectiveData.siteName || domain) : null const resolvedImageUrl = (effectiveData?.image && !imageError) ? effectiveData.image : (effectiveData?.originalImage && !originalImageError) ? effectiveData.originalImage : (fallbackImage && !fallbackImageError) ? fallbackImage : placeholderImageUrl const hasImage = !!resolvedImageUrl const isFallbackImage = resolvedImageUrl === fallbackImage const isPlaceholder = resolvedImageUrl === placeholderImageUrl && !isFallbackImage const bgColor = useImageEdgeColor(resolvedImageUrl ?? null, 'var(--color-bg-surface)') const renderSkeleton = () => isCompact ? (
) : (
) if (!url || typeof url !== 'string' || !isValidUrl) return renderSkeleton() if (isLocalhost) { return ( ) } if (loading) return renderSkeleton() if (!effectiveData) return renderSkeleton() const title = fallbackTitle || effectiveData.title // Empty string when the scrape returned nothing — descriptions render // conditionally below. Avoids the legacy `'No description available'` filler // that signaled "broken card" to users. const description = fallbackDescription || effectiveData.description || '' const ogDomain = getDomain(effectiveData.url) const faviconSrc = effectiveData.favicon || `https://www.google.com/s2/favicons?domain=${ogDomain}&sz=32` const logoSrc = publicationLogo || faviconSrc const handleImageError = () => { if (effectiveData.image && !imageError) setImageError(true) else if (effectiveData.originalImage && !originalImageError) setOriginalImageError(true) else setFallbackImageError(true) } const renderImage = () => { if (!resolvedImageUrl) return null if (isPlaceholder) { return ( {title} ) } if (isFallbackImage) { return ( {title} ) } return ( {title} ) } if (isCompact) { if (!hasImage) { return (
) } return ( ) } if (!hasImage) { return ( ) } return ( ) }