{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "link-preview",
  "type": "registry:component",
  "title": "Link Preview",
  "description": "A component to display a link preview for URLs included in Lens posts, showing the title, description, and image.",
  "dependencies": ["node-html-parser@7.0.1"],
  "registryDependencies": ["https://lensblocks.com/r/utils.json"],
  "files": [
    {
      "path": "registry/new-york/components/feed/link-preview.tsx",
      "content": "import { useEffect, useState } from \"react\";\nimport { UrlMetadata } from \"@/registry/new-york/lib/get-meta-from-url\";\nimport getFavicon from \"@/registry/new-york/lib/get-favicon\";\nimport { truncateUrl } from \"@/registry/new-york/lib/lens-utils\";\n\ntype Props = {\n  url: string;\n};\n\nexport const LinkPreview = (props: Props) => {\n  const { url } = props;\n\n  const [urlMetadata, setUrlMetadata] = useState<UrlMetadata | null>(null);\n\n  const favicon = getFavicon(url);\n\n  useEffect(() => {\n    if (!url) return;\n    fetch(`/api/url-meta?url=${encodeURIComponent(url)}`)\n      .then(r => r.json())\n      .then(setUrlMetadata);\n  }, [url]);\n\n  if (!urlMetadata) {\n    return null;\n  }\n\n  return (\n    <div onClick={event => event.stopPropagation()}>\n      <a\n        href={url}\n        className=\"w-full max-w-sm flex flex-col bg-card text-card-foreground border rounded-lg mb-4 cursor-pointer hover:!no-underline\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n      >\n        <div className=\"flex flex-col p-2 flex-1 min-w-0 gap-0.5 overflow-hidden\">\n          <div className=\"flex gap-1 items-center text-xs font-normal\">\n            <img\n              src={urlMetadata.icon ?? favicon}\n              width={24}\n              height={24}\n              alt={`${url} favicon`}\n              className=\"w-4 h-4 p-0.5\"\n            />\n            {urlMetadata.siteName && <div className=\"line-clamp-1 opacity-60\">{urlMetadata.siteName + \" • \"}</div>}\n            <div className=\"flex-none opacity-60\">{truncateUrl(urlMetadata.url)}</div>\n          </div>\n          {urlMetadata.title && <div className=\"line-clamp-1 !font-bold text-sm\">{urlMetadata.title}</div>}\n          {urlMetadata.description && (\n            <div className=\"line-clamp-2 text-xs !font-normal\">{urlMetadata.description}</div>\n          )}\n        </div>\n      </a>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "registry/new-york/lib/get-meta-from-url.ts",
      "content": "import { parse } from \"node-html-parser\";\n\nexport type UrlMetadata = {\n  url: string;\n  title: string | null;\n  description: string | null;\n  siteName: string | null;\n  icon: string | null;\n};\n\ntype Props = {\n  url: string;\n  userAgent?: string;\n};\n\nexport default async function getMetaFromUrl({ url, userAgent = \"LensBot/0.1 (like TwitterBot)\" }: Props) {\n  const res = await fetch(url, { headers: { \"User-Agent\": userAgent }, redirect: \"follow\" });\n  if (!res.ok) return { url, title: null, description: null, siteName: null, icon: null };\n\n  const html = await res.text();\n  const root = parse(html);\n\n  const pick = (names: string[]) => {\n    for (const name of names) {\n      const tag = root.querySelector(`meta[name=\"${name}\"]`) || root.querySelector(`meta[property=\"${name}\"]`);\n      const val = tag?.getAttribute(\"content\");\n      if (val) return val;\n    }\n    return null;\n  };\n\n  const title = pick([\"og:title\", \"twitter:title\"]) || root.querySelector(\"title\")?.text.trim() || null;\n\n  const description = pick([\"og:description\", \"twitter:description\", \"description\"]) || null;\n\n  const siteName = pick([\"og:site_name\"]) || null;\n\n  const iconTags = root.querySelectorAll(\n    'link[rel=\"icon\"], link[rel=\"shortcut icon\"], link[rel=\"apple-touch-icon\"], link[rel=\"mask-icon\"]',\n  );\n\n  let icon: string | null = null;\n  let bestScore = -1;\n  for (const el of iconTags) {\n    const href = el.getAttribute(\"href\");\n    if (!href) continue;\n    let score = href.toLowerCase().endsWith(\".svg\") ? 1000 : 0;\n    const sizes = el.getAttribute(\"sizes\");\n    if (sizes) {\n      const m = sizes.match(/(\\d+)x(\\d+)/);\n      if (m) score += Math.max(parseInt(m[1], 10), parseInt(m[2], 10));\n    }\n    if (score > bestScore) {\n      bestScore = score;\n      icon = new URL(href, url).toString();\n    }\n  }\n  if (!icon) icon = new URL(\"/favicon.ico\", url).toString();\n\n  return { url, title, description, siteName, icon };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/new-york/lib/get-favicon.ts",
      "content": "const FAVICON_BASE_URL = \"https://external-content.duckduckgo.com/ip3\";\nconst UNKNOWN_DOMAIN = \"unknowndomain\";\n\nconst getFavicon = (url: string): string => {\n  const domain = (() => {\n    try {\n      return new URL(url).hostname;\n    } catch {\n      return UNKNOWN_DOMAIN;\n    }\n  })();\n\n  return `${FAVICON_BASE_URL}/${domain || UNKNOWN_DOMAIN}.ico`;\n};\n\nexport default getFavicon;\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/new-york/files/api/url-meta/route.ts",
      "content": "import { NextResponse } from \"next/server\";\nimport getMetaFromUrl from \"@/registry/new-york/lib/get-meta-from-url\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(req: Request) {\n  const { searchParams } = new URL(req.url);\n  const url = searchParams.get(\"url\");\n  if (!url) return NextResponse.json({ error: \"url required\" }, { status: 400 });\n  const meta = await getMetaFromUrl({ url });\n  return NextResponse.json(meta, { headers: { \"Cache-Control\": \"max-age=0, s-maxage=3600\" } });\n}\n",
      "type": "registry:page",
      "target": "app/api/url-meta/route.ts"
    }
  ]
}
