{"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\n * @lumir-company/editor - Link Preview API Handler\n *\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\n *\n * Next.js App Router 사용 예시:\n * ```ts\n * // src/app/api/link-preview/route.ts\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\n * ```\n */\n\nexport interface LinkMetadata {\n  url: string;\n  title: string;\n  description?: string;\n  image?: string;\n  domain: string;\n}\n\nfunction extractDomain(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\nfunction decodeHtmlEntity(str: string): string {\n  return str\n    .replace(/&amp;/g, \"&\")\n    .replace(/&lt;/g, \"<\")\n    .replace(/&gt;/g, \">\")\n    .replace(/&quot;/g, '\"')\n    .replace(/&#39;/g, \"'\")\n    .replace(/&nbsp;/g, \" \")\n    .replace(/&#x27;/g, \"'\")\n    .replace(/&#x2F;/g, \"/\");\n}\n\nfunction getMetaContent(\n  html: string,\n  property: string,\n  name?: string\n): string | null {\n  const patterns = [\n    new RegExp(\n      `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n      \"i\"\n    ),\n    new RegExp(\n      `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\n      \"i\"\n    ),\n  ];\n\n  if (name) {\n    patterns.push(\n      new RegExp(\n        `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n        \"i\"\n      ),\n      new RegExp(\n        `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\n        \"i\"\n      )\n    );\n  }\n\n  for (const pattern of patterns) {\n    const match = html.match(pattern);\n    if (match?.[1]) return match[1].trim();\n  }\n\n  return null;\n}\n\n/**\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\n */\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\n  const domain = extractDomain(baseUrl);\n  const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\n\n  const ogTitle = getMetaContent(html, \"og:title\");\n  const ogDescription = getMetaContent(html, \"og:description\");\n  const ogImage = getMetaContent(html, \"og:image\");\n  const ogUrl = getMetaContent(html, \"og:url\");\n\n  const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\n  const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\n  const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\n\n  const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n  const descriptionMatch = html.match(\n    /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\n  );\n\n  if (ogTitle) {\n    metadata.title = decodeHtmlEntity(ogTitle);\n  } else if (twitterTitle) {\n    metadata.title = decodeHtmlEntity(twitterTitle);\n  } else if (titleMatch?.[1]) {\n    metadata.title = decodeHtmlEntity(titleMatch[1].trim());\n  }\n\n  if (ogDescription) {\n    metadata.description = decodeHtmlEntity(ogDescription);\n  } else if (twitterDescription) {\n    metadata.description = decodeHtmlEntity(twitterDescription);\n  } else if (descriptionMatch?.[1]) {\n    metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\n  }\n\n  let imageUrl: string | undefined;\n  if (ogImage) {\n    imageUrl = ogImage;\n  } else if (twitterImage) {\n    imageUrl = twitterImage;\n  }\n\n  if (imageUrl) {\n    imageUrl = decodeHtmlEntity(imageUrl);\n    if (imageUrl.trim()) {\n      try {\n        metadata.image = new URL(imageUrl, baseUrl).toString();\n      } catch {\n        metadata.image = undefined;\n      }\n    }\n  }\n\n  if (ogUrl) {\n    try {\n      metadata.url = new URL(ogUrl, baseUrl).toString();\n    } catch {\n      // keep original URL\n    }\n  }\n\n  return metadata;\n}\n\n/**\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\n */\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\n  const targetUrl = new URL(url);\n  if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n    throw new Error(\"Only http and https URLs are allowed\");\n  }\n\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), 8000);\n\n  try {\n    const response = await fetch(targetUrl.toString(), {\n      signal: controller.signal,\n      headers: {\n        \"User-Agent\":\n          \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n        Accept:\n          \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n        \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\n      },\n      redirect: \"follow\",\n    });\n\n    clearTimeout(timeoutId);\n\n    if (!response.ok) {\n      throw new Error(\n        `Failed to fetch URL: ${response.status} ${response.statusText}`\n      );\n    }\n\n    const html = await response.text();\n    return parseMetaTags(html, targetUrl.toString());\n  } catch (error) {\n    clearTimeout(timeoutId);\n    throw error;\n  }\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n  return new Response(JSON.stringify(data), {\n    status,\n    headers: { \"Content-Type\": \"application/json\" },\n  });\n}\n\n/**\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\n *\n * @example\n * // Next.js: src/app/api/link-preview/route.ts\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\n */\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\n  const { searchParams } = new URL(request.url);\n  const url = searchParams.get(\"url\");\n\n  if (!url) {\n    return jsonResponse({ error: \"url parameter is required\" }, 400);\n  }\n\n  let targetUrl: URL;\n  try {\n    targetUrl = new URL(url);\n    if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n      return jsonResponse(\n        { error: \"Only http and https URLs are allowed\" },\n        400\n      );\n    }\n  } catch {\n    return jsonResponse({ error: \"Invalid URL format\" }, 400);\n  }\n\n  try {\n    const metadata = await fetchUrlMetadata(targetUrl.toString());\n    return jsonResponse(metadata);\n  } catch (error: any) {\n    if (error.name === \"AbortError\") {\n      return jsonResponse({ error: \"Request timeout\" }, 408);\n    }\n\n    console.error(\"Error fetching link metadata:\", error);\n    return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\n  }\n}\n"],"mappings":"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","names":[]}