diff --git a/app/api/opengraph/route.ts b/app/api/opengraph/route.ts new file mode 100644 index 0000000..22e7347 --- /dev/null +++ b/app/api/opengraph/route.ts @@ -0,0 +1,90 @@ +// HTML 파일로 부터 메타데이터 파싱 +const getMeta = (html: string, prop: string): string | null => { + const patterns = [ + new RegExp( + `]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']*)["']`, + 'i' + ), + new RegExp( + `]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${prop}["']`, + 'i' + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match?.[1]?.trim()) return match[1].trim(); + } + return null; +}; + +// Open Graph API 엔드포인트 +export const GET = async (request: Request) => { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) return Response.json({ error: 'url required' }, { status: 400 }); + + try { + new URL(url); + } catch { + return Response.json({ error: 'invalid url' }, { status: 400 }); + } + + try { + const res = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + Accept: 'text/html,application/xhtml+xml', + }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) + return Response.json({ error: 'fetch failed' }, { status: 502 }); + + const html = await res.text(); + const urlObj = new URL(url); + + const title = + getMeta(html, 'og:title') || + getMeta(html, 'twitter:title') || + html.match(/]*>([^<]+)<\/title>/i)?.[1]?.trim() || + null; + + const description = + getMeta(html, 'og:description') || + getMeta(html, 'twitter:description') || + getMeta(html, 'description') || + null; + + const image = + getMeta(html, 'og:image') || + getMeta(html, 'twitter:image:src') || + getMeta(html, 'twitter:image') || + null; + + const siteName = getMeta(html, 'og:site_name') || null; + + const favicon = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`; + + return Response.json( + { + url, + title, + description, + image, + siteName, + favicon, + hostname: urlObj.hostname, + }, + { + headers: { + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600', + }, + } + ); + } catch { + return Response.json({ error: 'fetch failed' }, { status: 502 }); + } +}; diff --git a/app/entities/post/detail/OgLinkCard.tsx b/app/entities/post/detail/OgLinkCard.tsx new file mode 100644 index 0000000..2e1f8b4 --- /dev/null +++ b/app/entities/post/detail/OgLinkCard.tsx @@ -0,0 +1,130 @@ +'use client'; +import Image from 'next/image'; +import { useEffect, useState } from 'react'; +import Skeleton from '@/app/entities/common/Skeleton/Skeleton'; + +interface OGData { + url: string; + title: string | null; + description: string | null; + image: string | null; + siteName: string | null; + favicon: string | null; + hostname: string; +} + +const fetchOGData = async (href: string): Promise => { + const baseUrl = + process.env.NEXT_PUBLIC_DEPLOYMENT_URL || process.env.NEXT_PUBLIC_URL || ''; + const absoluteUrl = href.startsWith('/') ? `${baseUrl}${href}` : href; + try { + const res = await fetch( + `/api/opengraph?url=${encodeURIComponent(absoluteUrl)}` + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +}; + +const OgLinkCardSkeleton = () => ( +
+
+
+ + +
+ + + +
+
+
+); + +interface OgLinkCardProps { + href: string; +} + +const OgLinkCard = ({ href }: OgLinkCardProps) => { + const [data, setData] = useState(undefined); + + useEffect(() => { + fetchOGData(href).then(setData); + }, [href]); + + if (data === undefined) return ; + + if (!data || (!data.title && !data.description)) { + return ( + + {href} + + ); + } + + const { title, description, image, favicon, siteName, hostname } = data; + const siteLabel = siteName || hostname || ''; + + return ( + +
+
+ {favicon && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + + {siteLabel} + +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ {image && ( +
+ {title +
+ )} +
+ ); +}; + +export default OgLinkCard; diff --git a/app/entities/post/detail/PostBody.tsx b/app/entities/post/detail/PostBody.tsx index 00d6be5..6204292 100644 --- a/app/entities/post/detail/PostBody.tsx +++ b/app/entities/post/detail/PostBody.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator'; import ImageZoomOverlayContainer from '@/app/entities/common/Overlay/Image/ImageZoomOverlayContainer'; import Overlay from '@/app/entities/common/Overlay/Overlay'; +import OgLinkCard from '@/app/entities/post/detail/OgLinkCard'; import PostTOC from '@/app/entities/post/detail/PostTOC'; import TagBox from '@/app/entities/post/tags/TagBox'; import useOverlay from '@/app/hooks/common/useOverlay'; @@ -12,6 +13,7 @@ import { asideStyleRewrite, addDescriptionUnderImage, renderYoutubeEmbed, + renderOpenGraph, createImageClickHandler, } from '../../../lib/utils/rehypeUtils'; @@ -34,7 +36,6 @@ const PostBody = ({ content, tags, loading }: Props) => { const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay(); - // 이미지 클릭 핸들러 생성 const addImageClickHandler = createImageClickHandler( setSelectedImage, setOpenImageBox @@ -43,7 +44,7 @@ const PostBody = ({ content, tags, loading }: Props) => { return (
{loading ? ( @@ -75,9 +76,13 @@ const PostBody = ({ content, tags, loading }: Props) => { wrapperElement={{ 'data-color-mode': theme, }} + components={{ + ogcard: ({ href }: { href?: string }) => + href ? : null, + } as any} rehypeRewrite={(node, index?, parent?) => { asideStyleRewrite(node); - // renderOpenGraph(node, index || 0, parent as Element | undefined); + renderOpenGraph(node, index, parent as Element | undefined); renderYoutubeEmbed( node, index || 0, diff --git a/app/lib/utils/rehypeUtils.ts b/app/lib/utils/rehypeUtils.ts index 645b498..f9cede1 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -138,7 +138,8 @@ export const createYoutubeIframe = ( }; /** - * 내부 링크를 Open Graph 카드로 변환 (현재 비활성화) + * 링크를 감지하고 marker 노드로 변환 + * 실제 렌더링은 PostBody의 components prop에서 OgLinkCard 컴포넌트가 담당 */ export const renderOpenGraph = ( node: any, @@ -152,94 +153,34 @@ export const renderOpenGraph = ( if (!aTag) return; const href = aTag.properties?.href; - if (href && href.startsWith('/')) { - // 부모가 존재하고 children 배열이 있는 경우 - const opengraph = createOpenGraph(href); - if ( - index !== undefined && - parent && - parent.children && - Array.isArray(parent.children) - ) { - // 현재 a 태그 다음 위치에 div 삽입 - parent.children.splice(index + 1, 0, opengraph); - } else return; + if (!href || !(href.startsWith('/') || href.startsWith('http'))) return; + + const ogNode = { + type: 'element', + tagName: 'ogcard', + properties: { href }, + children: [], + }; + + if ( + index !== undefined && + parent?.children && + Array.isArray(parent.children) + ) { + // 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) →

대체 + // 커스텀 텍스트인 경우([text](url)) →

유지 후 카드 삽입 + const linkText = + aTag.children?.find((c: any) => c.type === 'text')?.value ?? ''; + const isUrlOnlyLink = linkText === href; + if (isUrlOnlyLink) { + parent.children.splice(index, 1, ogNode); + } else { + parent.children.splice(index + 1, 0, ogNode); + } } } }; -/** - * Open Graph 카드 노드 생성 - */ -export const createOpenGraph = (href: string) => { - return { - type: 'element', - tagName: 'a', - properties: { - className: 'open-graph', - href: href, - }, - children: [ - { - type: 'element', - tagName: 'img', - properties: { - src: `${href}`, - alt: 'Open Graph Image', - className: 'og-image', - }, - children: [], - }, - { - type: 'element', - tagName: 'div', - properties: { - className: 'og-container', - }, - children: [ - { - type: 'element', - tagName: 'h4', - properties: { - className: 'og-title', - }, - children: [ - { - type: 'text', - value: decodeURIComponent(href.split('/').pop()!).replaceAll( - '-', - ' ' - ), - }, - ], - }, - { - type: 'element', - tagName: 'span', - properties: { - className: 'og-content', - }, - children: [], - }, - { - type: 'element', - tagName: 'span', - properties: { - className: 'og-domain', - }, - children: [ - { - type: 'text', - value: '', - }, - ], - }, - ], - }, - ], - }; -}; - /** * 이미지 클릭 핸들러를 추가하는 함수 팩토리 * @param setSelectedImage - 선택된 이미지 URL을 설정하는 함수