import { type JSX, splitProps, createSignal, createEffect, createMemo, on, onMount, onCleanup, Show, } from 'solid-js'; import { cn } from '../utils/cn'; import { Link as LinkIcon } from 'lucide-solid'; import type { CardEvent } from '../primitives/card-contract'; import { type LinkPreviewData, deriveDomain, isRenderableLink, hasLinkPreviewFetcher, resolveLinkMetadata, } from '../primitives/link-preview'; export interface LinkPreviewProps { /** The card id correlating every emitted event. */ cardId: string; /** The link payload (data-down). */ data: LinkPreviewData; /** Emit a contract CardEvent up (host routes it). */ onEmit?: (event: CardEvent) => void; /** Extra classes for the card root. */ class?: string; } /** True when the payload already carries renderable metadata (the pure path). */ function hasMetadata(data: LinkPreviewData): boolean { return Boolean(data.title || data.description || data.image); } /** * `LinkPreview` — a pure, themed, accessible rich link / OG preview. Renders from the * supplied metadata; it never fetches the network itself. When the payload is a * bare `{ url }` and an app has registered a `configureLinkPreview` fetcher, it * shows a skeleton, calls the hook, merges the result, and renders. Activating the * card (click / Enter / Space) emits the contract `open` verb (`target:'tab'`); the * host policy performs the navigation so it can veto/redirect. */ export function LinkPreview(props: LinkPreviewProps): JSX.Element { const [local] = splitProps(props, ['cardId', 'data', 'onEmit', 'class']); const emit = (event: CardEvent) => local.onEmit?.(event); // Bare-URL resolution state (only used when the payload lacks metadata + a fetcher exists). const [fetched, setFetched] = createSignal | undefined>(); const [loading, setLoading] = createSignal(false); const [imageBroken, setImageBroken] = createSignal(false); const url = () => local.data.url; const valid = createMemo(() => isRenderableLink(url())); // The effective payload = supplied data merged with any fetched metadata. const effective = createMemo(() => ({ ...local.data, ...fetched() })); // Lifecycle `ready` once on mount. onMount(() => emit({ kind: 'ready', cardId: local.cardId })); // Invalid URL → emit a single `error` (belt-and-suspenders; host also rejects bad schemes on open). createEffect( on(valid, (ok) => { if (!ok) emit({ kind: 'error', cardId: local.cardId, message: `Invalid link url: ${url()}` }); }), ); // Bare-URL path: when there's no metadata AND a fetcher is configured, resolve once. createEffect( on( () => [local.data, valid()] as const, ([data, ok]) => { setImageBroken(false); if (!ok || hasMetadata(data) || !hasLinkPreviewFetcher()) return; let cancelled = false; // Cancel a stale in-flight fetch if `data` changes before it resolves. onCleanup(() => { cancelled = true; }); setLoading(true); resolveLinkMetadata(data.url) .then((meta) => { if (!cancelled) setFetched(meta); }) .catch(() => { // Reject → fall back to the bare link chip; the URL itself is still usable. if (!cancelled) setFetched({}); }) .finally(() => { if (!cancelled) setLoading(false); }); }, ), ); const domain = createMemo(() => effective().domain ?? deriveDomain(url()) ?? url()); const heading = createMemo(() => effective().siteName ?? domain()); const titleText = createMemo(() => effective().title ?? domain()); const showImage = createMemo(() => Boolean(effective().image) && !imageBroken()); const activate = () => { if (!valid()) return; emit({ kind: 'open', cardId: local.cardId, url: url(), target: 'tab' }); }; const onClick = (e: MouseEvent) => { // Intercept the anchor's default navigation so it routes through host policy. // (Middle-click / cmd-click still open the real href in a new tab — acceptable + safe.) e.preventDefault(); activate(); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }; // --- Invalid-link error chip ------------------------------------------ return (