{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notion-mention-link",
  "title": "Notion Mention Link",
  "description": "A Notion-inspired rich link mention with a securely fetched preview card on hover.",
  "dependencies": [
    "next"
  ],
  "registryDependencies": [
    "hover-card",
    "skeleton"
  ],
  "files": [
    {
      "path": "registry/components/unlumen/notion-mention-link/index.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { cn } from \"@/lib/utils\";\n\nexport type NotionMentionLinkMetadata = {\n  url: string;\n  title: string;\n  description?: string;\n  siteName: string;\n  domain: string;\n  image?: string;\n  favicon?: string;\n};\n\nexport type NotionMentionLinkProps = Omit<\n  React.ComponentPropsWithRef<\"a\">,\n  \"children\" | \"href\"\n> & {\n  url: string;\n  endpoint?: string;\n  metadata?: NotionMentionLinkMetadata;\n  prefetch?: \"mount\" | \"hover\";\n  openDelay?: number;\n  closeDelay?: number;\n  previewWidth?: number;\n  previewHeight?: number;\n  align?: \"start\" | \"end\";\n  showPreview?: boolean;\n  invalidLabel?: string;\n  previewClassName?: string;\n};\n\nconst metadataCache = new Map<string, NotionMentionLinkMetadata>();\n\nfunction normalizeUrl(value: string) {\n  const trimmed = value.trim();\n  if (!trimmed) return \"\";\n\n  return /^https?:\\/\\//i.test(trimmed) ? trimmed : `https://${trimmed}`;\n}\n\nfunction isValidWebsiteUrl(value: string) {\n  if (!value || /\\s/.test(value)) return false;\n\n  try {\n    const parsed = new URL(value);\n    const labels = parsed.hostname.split(\".\");\n\n    return (\n      [\"http:\", \"https:\"].includes(parsed.protocol) &&\n      labels.length >= 2 &&\n      labels.every((label) =>\n        /^[a-z\\d](?:[a-z\\d-]{0,61}[a-z\\d])?$/i.test(label),\n      )\n    );\n  } catch {\n    return false;\n  }\n}\n\nfunction getDomain(url: string) {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\nfunction getFallbackMetadata(url: string): NotionMentionLinkMetadata {\n  const domain = getDomain(url);\n\n  return {\n    url,\n    title: domain,\n    siteName: domain.split(\".\")[0] || domain,\n    domain,\n  };\n}\n\nfunction getAssetUrl(endpoint: string, url?: string) {\n  if (!url) return undefined;\n\n  const params = new URLSearchParams({ asset: \"1\", url });\n  return `${endpoint}${endpoint.includes(\"?\") ? \"&\" : \"?\"}${params.toString()}`;\n}\n\nfunction SiteIcon({\n  src,\n  label,\n  large = false,\n}: {\n  src?: string;\n  label: string;\n  large?: boolean;\n}) {\n  const [failed, setFailed] = React.useState(false);\n\n  React.useEffect(() => setFailed(false), [src]);\n\n  if (!src || failed) {\n    return (\n      <span\n        className={cn(\n          \"inline-flex shrink-0 items-center justify-center rounded bg-foreground font-semibold uppercase text-background\",\n          large ? \"size-6 text-[10px]\" : \"size-[22px] text-[9px]\",\n        )}\n        aria-hidden=\"true\"\n      >\n        {label.charAt(0)}\n      </span>\n    );\n  }\n\n  return (\n    // The same-origin endpoint validates and proxies this remote asset.\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      src={src}\n      alt=\"\"\n      className={cn(\n        \"shrink-0 rounded object-cover\",\n        large ? \"size-6\" : \"size-[22px]\",\n      )}\n      onError={() => setFailed(true)}\n      aria-hidden=\"true\"\n    />\n  );\n}\n\nfunction PreviewSkeleton({ height }: { height: number }) {\n  return (\n    <div className=\"overflow-hidden rounded-xl border border-border bg-card shadow-xl shadow-foreground/10\">\n      <Skeleton className=\"rounded-none\" style={{ height }} />\n      <div className=\"flex flex-col gap-3 p-5\">\n        <Skeleton className=\"h-5 w-4/5\" />\n        <Skeleton className=\"h-4 w-full\" />\n        <Skeleton className=\"h-4 w-2/3\" />\n      </div>\n    </div>\n  );\n}\n\nfunction PreviewImage({ src, height }: { src: string; height: number }) {\n  const [failed, setFailed] = React.useState(false);\n\n  React.useEffect(() => setFailed(false), [src]);\n\n  if (failed) return null;\n\n  return (\n    // The same-origin endpoint validates and proxies this remote asset.\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      src={src}\n      alt=\"\"\n      className=\"block w-full bg-muted object-cover\"\n      style={{ height }}\n      onError={() => setFailed(true)}\n      aria-hidden=\"true\"\n    />\n  );\n}\n\nfunction NotionMentionLink({\n  url,\n  endpoint = \"/api/notion-mention-link\",\n  metadata: suppliedMetadata,\n  prefetch = \"mount\",\n  openDelay = 180,\n  closeDelay = 120,\n  previewWidth = 310,\n  previewHeight = 140,\n  align = \"start\",\n  showPreview = true,\n  invalidLabel = \"Enter a valid website URL.\",\n  previewClassName,\n  className,\n  target = \"_blank\",\n  rel = \"noopener noreferrer\",\n  onMouseEnter,\n  onMouseLeave,\n  onFocus,\n  onBlur,\n  ref,\n  ...props\n}: NotionMentionLinkProps) {\n  const normalizedUrl = React.useMemo(() => normalizeUrl(url), [url]);\n  const isValidUrl = React.useMemo(\n    () => isValidWebsiteUrl(normalizedUrl),\n    [normalizedUrl],\n  );\n  const fallback = React.useMemo(\n    () => getFallbackMetadata(normalizedUrl),\n    [normalizedUrl],\n  );\n  const [loadedMetadata, setLoadedMetadata] = React.useState<\n    NotionMentionLinkMetadata | undefined\n  >(() => suppliedMetadata ?? metadataCache.get(normalizedUrl));\n  const [status, setStatus] = React.useState<\n    \"idle\" | \"loading\" | \"ready\" | \"error\" | \"invalid\"\n  >(\n    !isValidUrl\n      ? \"invalid\"\n      : suppliedMetadata || metadataCache.has(normalizedUrl)\n        ? \"ready\"\n        : \"idle\",\n  );\n  const requestRef = React.useRef<AbortController | null>(null);\n  const previewId = React.useId();\n  const currentMetadata = suppliedMetadata ?? loadedMetadata ?? fallback;\n\n  const loadMetadata = React.useCallback(async () => {\n    if (!isValidUrl) {\n      setStatus(\"invalid\");\n      return;\n    }\n\n    if (suppliedMetadata || metadataCache.has(normalizedUrl)) {\n      const cached = metadataCache.get(normalizedUrl);\n      if (cached) {\n        setLoadedMetadata(cached);\n        setStatus(\"ready\");\n      }\n      return;\n    }\n\n    requestRef.current?.abort();\n    const controller = new AbortController();\n    requestRef.current = controller;\n    setStatus(\"loading\");\n\n    try {\n      const separator = endpoint.includes(\"?\") ? \"&\" : \"?\";\n      const response = await fetch(\n        `${endpoint}${separator}${new URLSearchParams({ url: normalizedUrl }).toString()}`,\n        { signal: controller.signal },\n      );\n\n      if (!response.ok) throw new Error(\"Preview request failed\");\n\n      const data = (await response.json()) as NotionMentionLinkMetadata;\n      metadataCache.set(normalizedUrl, data);\n      setLoadedMetadata(data);\n      setStatus(\"ready\");\n    } catch (error) {\n      if (error instanceof DOMException && error.name === \"AbortError\") return;\n      setStatus(\"error\");\n    }\n  }, [endpoint, isValidUrl, normalizedUrl, suppliedMetadata]);\n\n  React.useEffect(() => {\n    requestRef.current?.abort();\n    setLoadedMetadata(suppliedMetadata ?? metadataCache.get(normalizedUrl));\n    setStatus(\n      !isValidUrl\n        ? \"invalid\"\n        : suppliedMetadata || metadataCache.has(normalizedUrl)\n          ? \"ready\"\n          : \"idle\",\n    );\n  }, [isValidUrl, normalizedUrl, suppliedMetadata]);\n\n  React.useEffect(() => {\n    if (prefetch === \"mount\") void loadMetadata();\n\n    return () => {\n      requestRef.current?.abort();\n    };\n  }, [loadMetadata, prefetch]);\n\n  const faviconUrl = getAssetUrl(endpoint, currentMetadata.favicon);\n  const imageUrl = getAssetUrl(endpoint, currentMetadata.image);\n\n  if (!isValidUrl || status === \"invalid\") {\n    return (\n      <span\n        role=\"status\"\n        className=\"inline-flex min-h-7 items-center text-sm text-destructive\"\n      >\n        {invalidLabel}\n      </span>\n    );\n  }\n\n  const mention = (\n    <a\n      ref={ref}\n      href={normalizedUrl}\n      target={target}\n      rel={rel}\n      className={cn(\n        \"inline-flex max-w-full items-center gap-1.5 rounded-[5px] px-1 py-0.5 text-[15px] leading-6 text-foreground outline-none transition-colors duration-100\",\n        \"hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onMouseEnter={(event) => {\n        if (prefetch === \"hover\" || status === \"idle\") void loadMetadata();\n        onMouseEnter?.(event);\n      }}\n      onMouseLeave={(event) => {\n        onMouseLeave?.(event);\n      }}\n      onFocus={(event) => {\n        if (prefetch === \"hover\" || status === \"idle\") void loadMetadata();\n        onFocus?.(event);\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n      }}\n      {...props}\n    >\n      <SiteIcon src={faviconUrl} label={currentMetadata.siteName} />\n      <span className=\"min-w-0 truncate text-muted-foreground\">\n        {currentMetadata.siteName}\n      </span>\n      <span className=\"min-w-0 truncate font-medium underline decoration-foreground/35 underline-offset-[3px]\">\n        {currentMetadata.title}\n      </span>\n    </a>\n  );\n\n  if (!showPreview) return mention;\n\n  return (\n    <HoverCard\n      openDelay={openDelay}\n      closeDelay={closeDelay}\n      onOpenChange={(nextOpen) => {\n        if (nextOpen && (prefetch === \"hover\" || status === \"idle\")) {\n          void loadMetadata();\n        }\n      }}\n    >\n      <HoverCardTrigger asChild>{mention}</HoverCardTrigger>\n      <HoverCardContent\n        id={previewId}\n        align={align}\n        side=\"bottom\"\n        sideOffset={8}\n        style={{ width: previewWidth }}\n        className={cn(\n          \"max-w-[calc(100vw-2rem)] border-0 bg-transparent p-0 shadow-none ring-0 will-change-transform\",\n          \"data-[state=open]:animate-in data-[state=open]:fade-in-0  data-[state=open]:slide-in-from-top-1 data-[state=open]:duration-150\",\n          \"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-top-1 data-[state=closed]:duration-150\",\n          previewClassName,\n        )}\n      >\n        {status === \"loading\" || status === \"idle\" ? (\n          <PreviewSkeleton height={previewHeight} />\n        ) : (\n          <span className=\"block overflow-hidden rounded-xl border border-border bg-card text-left shadow-xl shadow-foreground/10\">\n            {imageUrl ? (\n              <PreviewImage src={imageUrl} height={previewHeight} />\n            ) : null}\n\n            <span className=\"flex flex-col gap-2 px-5 pb-5 pt-4\">\n              <strong className=\"line-clamp-2 text-lg font-semibold leading-6 text-foreground\">\n                {currentMetadata.title}\n              </strong>\n              {status === \"error\" ? (\n                <span className=\"text-sm leading-5 text-muted-foreground\">\n                  Preview unavailable. The link is still safe to open directly.\n                </span>\n              ) : currentMetadata.description ? (\n                <span className=\"line-clamp-3 text-sm leading-5 text-foreground/85\">\n                  {currentMetadata.description}\n                </span>\n              ) : null}\n              <span className=\"mt-2 flex items-center gap-2 text-sm font-medium text-muted-foreground\">\n                <SiteIcon\n                  src={faviconUrl}\n                  label={currentMetadata.siteName}\n                  large\n                />\n                <span className=\"truncate\">{currentMetadata.domain}</span>\n              </span>\n            </span>\n          </span>\n        )}\n      </HoverCardContent>\n    </HoverCard>\n  );\n}\n\nexport { NotionMentionLink };\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/notion-mention-link.tsx"
    },
    {
      "path": "registry/components/unlumen/notion-mention-link/link-preview.server.ts",
      "content": "import { lookup } from \"node:dns/promises\";\nimport http from \"node:http\";\nimport https from \"node:https\";\nimport { isIP } from \"node:net\";\n\nexport type SafeLinkPreview = {\n  url: string;\n  title: string;\n  description?: string;\n  siteName: string;\n  domain: string;\n  image?: string;\n  favicon?: string;\n};\n\ntype SafeResponse = {\n  body: Buffer;\n  contentType: string;\n  finalUrl: URL;\n};\n\nconst MAX_HTML_BYTES = 512_000;\nconst MAX_ASSET_BYTES = 5_000_000;\nconst MAX_REDIRECTS = 3;\nconst REQUEST_TIMEOUT_MS = 5_000;\n\nfunction isPrivateIpv4(address: string) {\n  const octets = address.split(\".\").map(Number);\n  const [a, b] = octets;\n\n  return (\n    a === 0 ||\n    a === 10 ||\n    a === 127 ||\n    (a === 169 && b === 254) ||\n    (a === 172 && b !== undefined && b >= 16 && b <= 31) ||\n    (a === 192 && b === 168) ||\n    (a === 100 && b !== undefined && b >= 64 && b <= 127) ||\n    (a === 192 && b === 0) ||\n    (a === 198 && (b === 18 || b === 19)) ||\n    (a === 198 && b === 51 && octets[2] === 100) ||\n    (a === 203 && b === 0 && octets[2] === 113) ||\n    (a !== undefined && a >= 224)\n  );\n}\n\nfunction isPrivateIpv6(address: string) {\n  const normalized = address.toLowerCase().split(\"%\")[0] ?? address;\n\n  return (\n    normalized === \"::\" ||\n    normalized === \"::1\" ||\n    normalized.startsWith(\"fc\") ||\n    normalized.startsWith(\"fd\") ||\n    /^fe[89ab]/.test(normalized) ||\n    normalized.startsWith(\"ff\") ||\n    normalized.startsWith(\"2001:db8:\") ||\n    (normalized.startsWith(\"::ffff:\") &&\n      isPrivateIpv4(normalized.slice(\"::ffff:\".length)))\n  );\n}\n\nfunction isPublicAddress(address: string) {\n  const version = isIP(address);\n  if (version === 4) return !isPrivateIpv4(address);\n  if (version === 6) return !isPrivateIpv6(address);\n  return false;\n}\n\nasync function validateUrl(input: string) {\n  const url = new URL(input);\n\n  if (![\"http:\", \"https:\"].includes(url.protocol)) {\n    throw new Error(\"Only HTTP and HTTPS URLs are supported\");\n  }\n  if (url.username || url.password)\n    throw new Error(\"URL credentials are not allowed\");\n  if (url.port && ![\"80\", \"443\"].includes(url.port)) {\n    throw new Error(\"Non-standard ports are not allowed\");\n  }\n\n  const hostname = url.hostname.toLowerCase().replace(/\\.$/, \"\");\n  if (\n    hostname === \"localhost\" ||\n    hostname.endsWith(\".localhost\") ||\n    hostname.endsWith(\".local\") ||\n    hostname.endsWith(\".internal\")\n  ) {\n    throw new Error(\"Local network hosts are not allowed\");\n  }\n\n  const literalVersion = isIP(hostname);\n  const addresses = literalVersion\n    ? [{ address: hostname, family: literalVersion }]\n    : await lookup(hostname, { all: true, verbatim: true });\n\n  if (\n    addresses.length === 0 ||\n    addresses.some(({ address }) => !isPublicAddress(address))\n  ) {\n    throw new Error(\"Private or unresolved hosts are not allowed\");\n  }\n\n  return { url, addresses };\n}\n\nasync function requestUrl(\n  input: string,\n  options: { maxBytes: number; accept: string },\n  redirects = 0,\n): Promise<SafeResponse> {\n  const { url, addresses } = await validateUrl(input);\n  const selected = addresses[0];\n  if (!selected) throw new Error(\"Host did not resolve\");\n\n  const transport = url.protocol === \"https:\" ? https : http;\n\n  return new Promise((resolve, reject) => {\n    const request = transport.request(\n      url,\n      {\n        method: \"GET\",\n        headers: {\n          accept: options.accept,\n          \"accept-encoding\": \"identity\",\n          \"user-agent\": \"UnlumenLinkPreview/1.0 (+https://ui.unlumen.com)\",\n        },\n        lookup: (_hostname, lookupOptions, callback) => {\n          if (typeof lookupOptions === \"object\" && lookupOptions.all) {\n            callback(null, [selected]);\n            return;\n          }\n\n          callback(null, selected.address, selected.family as 4 | 6);\n        },\n      },\n      (response) => {\n        const status = response.statusCode ?? 500;\n        const location = response.headers.location;\n\n        if (status >= 300 && status < 400 && location) {\n          response.resume();\n          if (redirects >= MAX_REDIRECTS) {\n            reject(new Error(\"Too many redirects\"));\n            return;\n          }\n\n          const redirectUrl = new URL(location, url).toString();\n          void requestUrl(redirectUrl, options, redirects + 1).then(\n            resolve,\n            reject,\n          );\n          return;\n        }\n\n        if (status < 200 || status >= 300) {\n          response.resume();\n          reject(new Error(`Remote server returned ${status}`));\n          return;\n        }\n\n        const declaredLength = Number(response.headers[\"content-length\"] ?? 0);\n        if (declaredLength > options.maxBytes) {\n          response.destroy(new Error(\"Remote response is too large\"));\n          return;\n        }\n\n        const chunks: Buffer[] = [];\n        let received = 0;\n\n        response.on(\"data\", (chunk: Buffer) => {\n          received += chunk.length;\n          if (received > options.maxBytes) {\n            response.destroy(new Error(\"Remote response is too large\"));\n            return;\n          }\n          chunks.push(chunk);\n        });\n        response.on(\"end\", () => {\n          resolve({\n            body: Buffer.concat(chunks),\n            contentType: String(response.headers[\"content-type\"] ?? \"\"),\n            finalUrl: url,\n          });\n        });\n        response.on(\"error\", reject);\n      },\n    );\n\n    request.setTimeout(REQUEST_TIMEOUT_MS, () => {\n      request.destroy(new Error(\"Remote request timed out\"));\n    });\n    request.on(\"error\", reject);\n    request.end();\n  });\n}\n\nfunction decodeEntities(value: string) {\n  const entities: Record<string, string> = {\n    amp: \"&\",\n    apos: \"'\",\n    gt: \">\",\n    lt: \"<\",\n    quot: '\"',\n  };\n\n  return value\n    .replace(/&#(\\d+);/g, (_, code: string) =>\n      String.fromCodePoint(Number(code)),\n    )\n    .replace(/&#x([\\da-f]+);/gi, (_, code: string) =>\n      String.fromCodePoint(Number.parseInt(code, 16)),\n    )\n    .replace(\n      /&([a-z]+);/gi,\n      (entity, name: string) => entities[name.toLowerCase()] ?? entity,\n    )\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nfunction getAttribute(tag: string, name: string) {\n  const match = tag.match(new RegExp(`\\\\b${name}\\\\s*=\\\\s*([\"'])(.*?)\\\\1`, \"i\"));\n  return match?.[2] ? decodeEntities(match[2]) : undefined;\n}\n\nfunction getMeta(html: string, keys: string[]) {\n  for (const tag of html.match(/<meta\\b[^>]*>/gi) ?? []) {\n    const key = getAttribute(tag, \"property\") ?? getAttribute(tag, \"name\");\n    if (key && keys.includes(key.toLowerCase())) {\n      const content = getAttribute(tag, \"content\");\n      if (content) return content;\n    }\n  }\n  return undefined;\n}\n\nfunction getFavicon(html: string) {\n  for (const tag of html.match(/<link\\b[^>]*>/gi) ?? []) {\n    const rel = getAttribute(tag, \"rel\")?.toLowerCase();\n    if (rel?.split(/\\s+/).includes(\"icon\")) return getAttribute(tag, \"href\");\n  }\n  return undefined;\n}\n\nfunction resolveRemoteUrl(value: string | undefined, base: URL) {\n  if (!value) return undefined;\n  try {\n    const url = new URL(value, base);\n    return [\"http:\", \"https:\"].includes(url.protocol)\n      ? url.toString()\n      : undefined;\n  } catch {\n    return undefined;\n  }\n}\n\nexport async function getSafeLinkPreview(\n  input: string,\n): Promise<SafeLinkPreview> {\n  const response = await requestUrl(input, {\n    maxBytes: MAX_HTML_BYTES,\n    accept: \"text/html,application/xhtml+xml\",\n  });\n\n  if (!response.contentType.toLowerCase().includes(\"text/html\")) {\n    throw new Error(\"The URL did not return HTML\");\n  }\n\n  const html = response.body.toString(\"utf8\");\n  const titleTag = html.match(/<title\\b[^>]*>([\\s\\S]*?)<\\/title>/i)?.[1];\n  const domain = response.finalUrl.hostname.replace(/^www\\./, \"\");\n  const title =\n    getMeta(html, [\"og:title\", \"twitter:title\"]) ??\n    (titleTag ? decodeEntities(titleTag.replace(/<[^>]+>/g, \"\")) : undefined) ??\n    domain;\n  const siteName =\n    getMeta(html, [\"og:site_name\"]) ?? domain.split(\".\")[0] ?? domain;\n\n  return {\n    url: response.finalUrl.toString(),\n    title: title.slice(0, 200),\n    description: getMeta(html, [\n      \"og:description\",\n      \"twitter:description\",\n      \"description\",\n    ])?.slice(0, 500),\n    siteName: siteName.slice(0, 80),\n    domain,\n    image: resolveRemoteUrl(\n      getMeta(html, [\"og:image:secure_url\", \"og:image\", \"twitter:image\"]),\n      response.finalUrl,\n    ),\n    favicon: resolveRemoteUrl(\n      getFavicon(html) ?? \"/favicon.ico\",\n      response.finalUrl,\n    ),\n  };\n}\n\nexport async function getSafeRemoteAsset(input: string) {\n  const response = await requestUrl(input, {\n    maxBytes: MAX_ASSET_BYTES,\n    accept:\n      \"image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml\",\n  });\n\n  if (!response.contentType.toLowerCase().startsWith(\"image/\")) {\n    throw new Error(\"The URL did not return an image\");\n  }\n\n  return { body: response.body, contentType: response.contentType };\n}\n",
      "type": "registry:lib",
      "target": "lib/notion-link-preview.server.ts"
    },
    {
      "path": "registry/components/unlumen/notion-mention-link/route.ts",
      "content": "import { NextResponse, type NextRequest } from \"next/server\";\n\nimport {\n  getSafeLinkPreview,\n  getSafeRemoteAsset,\n} from \"@/lib/notion-link-preview.server\";\n\nexport const runtime = \"nodejs\";\n\nconst RATE_LIMIT_WINDOW_MS = 60_000;\nconst RATE_LIMIT_MAX_REQUESTS = 60;\nconst rateLimitBuckets = new Map<string, number[]>();\n\nfunction isRateLimited(request: NextRequest) {\n  const forwardedFor = request.headers.get(\"x-forwarded-for\")?.split(\",\")[0];\n  const client =\n    forwardedFor?.trim() || request.headers.get(\"x-real-ip\") || \"anonymous\";\n  const now = Date.now();\n  const recent = (rateLimitBuckets.get(client) ?? []).filter(\n    (timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS,\n  );\n\n  if (recent.length >= RATE_LIMIT_MAX_REQUESTS) return true;\n  rateLimitBuckets.set(client, [...recent, now]);\n\n  if (rateLimitBuckets.size > 1_000) {\n    for (const [key, timestamps] of rateLimitBuckets) {\n      if (\n        timestamps.every((timestamp) => now - timestamp >= RATE_LIMIT_WINDOW_MS)\n      ) {\n        rateLimitBuckets.delete(key);\n      }\n    }\n  }\n\n  return false;\n}\n\nexport async function GET(request: NextRequest) {\n  if (isRateLimited(request)) {\n    return NextResponse.json(\n      { error: \"Too many preview requests.\" },\n      { status: 429, headers: { \"Retry-After\": \"60\" } },\n    );\n  }\n\n  const url = request.nextUrl.searchParams.get(\"url\");\n  if (!url || url.length > 2_048) {\n    return NextResponse.json(\n      { error: \"A valid URL is required.\" },\n      { status: 400 },\n    );\n  }\n\n  try {\n    if (request.nextUrl.searchParams.get(\"asset\") === \"1\") {\n      const asset = await getSafeRemoteAsset(url);\n      return new NextResponse(new Uint8Array(asset.body), {\n        headers: {\n          \"Cache-Control\": \"public, max-age=3600, stale-while-revalidate=86400\",\n          \"Content-Type\": asset.contentType,\n          \"X-Content-Type-Options\": \"nosniff\",\n        },\n      });\n    }\n\n    const preview = await getSafeLinkPreview(url);\n    return NextResponse.json(preview, {\n      headers: {\n        \"Cache-Control\": \"public, s-maxage=3600, stale-while-revalidate=86400\",\n        \"X-Content-Type-Options\": \"nosniff\",\n      },\n    });\n  } catch (error) {\n    console.warn(\"Notion mention preview rejected\", error);\n    return NextResponse.json(\n      { error: \"Preview unavailable.\" },\n      { status: 422 },\n    );\n  }\n}\n",
      "type": "registry:page",
      "target": "app/api/notion-mention-link/route.ts"
    }
  ],
  "type": "registry:ui"
}