From ad976e56384ced92cc8fad00fa1f42f02b174178 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sat, 14 Mar 2026 21:53:58 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20og=20card=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/detail/PostBody.tsx | 20 ++- app/lib/utils/rehypeUtils.ts | 232 ++++++++++++++++++-------- 2 files changed, 182 insertions(+), 70 deletions(-) diff --git a/app/entities/post/detail/PostBody.tsx b/app/entities/post/detail/PostBody.tsx index 00d6be5..82572f4 100644 --- a/app/entities/post/detail/PostBody.tsx +++ b/app/entities/post/detail/PostBody.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, 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'; @@ -12,7 +12,11 @@ import { asideStyleRewrite, addDescriptionUnderImage, renderYoutubeEmbed, + renderOpenGraph, createImageClickHandler, + extractInternalLinks, + fetchOGData, + OGData, } from '../../../lib/utils/rehypeUtils'; interface Props { @@ -33,6 +37,18 @@ const PostBody = ({ content, tags, loading }: Props) => { ); const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay(); + const [ogDataMap, setOgDataMap] = useState>({}); + + useEffect(() => { + const links = extractInternalLinks(content); + if (links.length === 0) return; + Promise.all( + links.map(async (href) => { + const data = await fetchOGData(href); + return [href, data] as [string, OGData | null]; + }) + ).then((results) => setOgDataMap(Object.fromEntries(results))); + }, [content]); // 이미지 클릭 핸들러 생성 const addImageClickHandler = createImageClickHandler( @@ -77,7 +93,7 @@ const PostBody = ({ content, tags, loading }: Props) => { }} rehypeRewrite={(node, index?, parent?) => { asideStyleRewrite(node); - // renderOpenGraph(node, index || 0, parent as Element | undefined); + renderOpenGraph(node, index, parent as Element | undefined, ogDataMap); renderYoutubeEmbed( node, index || 0, diff --git a/app/lib/utils/rehypeUtils.ts b/app/lib/utils/rehypeUtils.ts index 645b498..06bc444 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -4,6 +4,56 @@ import { SelectedImage } from '../../entities/post/detail/PostBody'; +export interface OGData { + url: string; + title: string | null; + description: string | null; + image: string | null; + siteName: string | null; + favicon: string | null; + hostname: string; +} + +/** + * 마크다운에서 OG 카드로 변환할 링크(내부/외부) 추출 + */ +export const extractInternalLinks = (markdown: string): string[] => { + const links: string[] = []; + + // [text](url) 형식 — 내부(/로 시작) 또는 외부(http(s)://) URL + const mdLinkRegex = /\[([^\]]*)\]\(((?:https?:\/\/|\/)[^)]+)\)/g; + let match; + while ((match = mdLinkRegex.exec(markdown)) !== null) { + links.push(match[2]); + } + + // 마크다운 링크 문법 밖의 bare URL (ex. 단독으로 쓴 https://...) + const bareUrlRegex = /(?)"]+/g; + while ((match = bareUrlRegex.exec(markdown)) !== null) { + links.push(match[0]); + } + + return Array.from(new Set(links)); +}; + +/** + * /api/opengraph를 통해 OG 데이터 fetch + */ +export 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; + } +}; + /** * aside 태그의 첫 번째 텍스트 노드를 emoji span으로 래핑 */ @@ -38,6 +88,14 @@ export const addDescriptionUnderImage = ( parent?: Element ) => { if (node.type === 'element' && node.tagName === 'img') { + const parentEl = parent as any; + const isInsideOGCard = + (parentEl?.tagName === 'a' && + parentEl?.properties?.className?.includes('rounded-xl')) || + (parentEl?.tagName === 'div' && + parentEl?.properties?.className?.includes('aspect-video')); + if (isInsideOGCard) return null; + const altText = node.properties.alt; if (altText) { const descriptionNode = { @@ -138,12 +196,13 @@ export const createYoutubeIframe = ( }; /** - * 내부 링크를 Open Graph 카드로 변환 (현재 비활성화) + * 내부 링크를 Open Graph 카드로 변환 */ export const renderOpenGraph = ( node: any, index?: number, - parent?: Element + parent?: Element, + ogDataMap?: Record ) => { if (node.type === 'element' && node.tagName === 'p' && node.children) { const aTag = node.children.find( @@ -152,17 +211,19 @@ export const renderOpenGraph = ( if (!aTag) return; const href = aTag.properties?.href; - if (href && href.startsWith('/')) { - // 부모가 존재하고 children 배열이 있는 경우 - const opengraph = createOpenGraph(href); + const isOGTarget = + href && (href.startsWith('/') || href.startsWith('http')); + if (isOGTarget && ogDataMap && href in ogDataMap) { + const data = ogDataMap[href]; + if (!data || (!data.title && !data.description)) return; + const ogCard = createOpenGraph(href, data); if ( index !== undefined && parent && parent.children && Array.isArray(parent.children) ) { - // 현재 a 태그 다음 위치에 div 삽입 - parent.children.splice(index + 1, 0, opengraph); + parent.children.splice(index + 1, 0, ogCard); } else return; } } @@ -171,72 +232,107 @@ export const renderOpenGraph = ( /** * Open Graph 카드 노드 생성 */ -export const createOpenGraph = (href: string) => { +export const createOpenGraph = (href: string, data: OGData) => { + const { title, description, image, favicon, siteName, hostname } = data; + const siteLabel = siteName || hostname || ''; + + const siteInfoChildren: any[] = []; + if (favicon) { + siteInfoChildren.push({ + type: 'element', + tagName: 'img', + properties: { + src: favicon, + alt: '', + width: '14', + height: '14', + className: 'shrink-0 rounded-sm', + }, + children: [], + }); + } + siteInfoChildren.push({ + type: 'element', + tagName: 'span', + properties: { className: 'text-muted-foreground truncate text-xs' }, + children: [{ type: 'text', value: siteLabel }], + }); + + const textChildren: any[] = [ + { + type: 'element', + tagName: 'div', + properties: { className: 'flex items-center gap-1.5 h-4' }, + children: siteInfoChildren, + }, + ]; + if (title) { + textChildren.push({ + type: 'element', + tagName: 'p', + properties: { + className: + 'text-foreground line-clamp-1 text-sm font-semibold leading-snug !mb-1', + }, + children: [{ type: 'text', value: title }], + }); + } + if (description) { + textChildren.push({ + type: 'element', + tagName: 'p', + properties: { + className: + 'text-muted-foreground line-clamp-2 text-xs leading-relaxed !mb-0', + }, + children: [{ type: 'text', value: description }], + }); + } + + const cardChildren: any[] = [ + { + type: 'element', + tagName: 'div', + properties: { + className: 'flex min-w-0 flex-1 flex-col justify-center gap-0 px-4', + }, + children: textChildren, + }, + ]; + if (image) { + cardChildren.push({ + type: 'element', + tagName: 'div', + properties: { + className: 'relative hidden aspect-video shrink-0 sm:block', + style: 'width: 120px', + }, + children: [ + { + type: 'element', + tagName: 'img', + properties: { + src: image, + alt: title ?? '', + className: 'h-full !w-full object-cover !m-0 !p-0 !max-w-none', + }, + children: [], + }, + ], + }); + } + return { type: 'element', tagName: 'a', properties: { - className: 'open-graph', - href: href, + href, + target: '_blank', + rel: 'noopener noreferrer', + className: + 'border-border bg-card hover:border-primary/40 hover:bg-muted/50 flex overflow-hidden rounded-xl border transition-colors', }, - 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: '', - }, - ], - }, - ], - }, - ], + children: cardChildren, }; }; From cbb6662faa22abb72175aa3929018a467f331484 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sat, 14 Mar 2026 21:54:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20og=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/opengraph/route.ts | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/api/opengraph/route.ts 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 }); + } +}; From eb38c899c3bb3d0bd6bd9d739d5e5a33d6a720bb Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sat, 14 Mar 2026 22:05:05 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=EB=A7=8C=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20og=20card=EB=A7=8C=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/utils/rehypeUtils.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/lib/utils/rehypeUtils.ts b/app/lib/utils/rehypeUtils.ts index 06bc444..4a82520 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -223,7 +223,15 @@ export const renderOpenGraph = ( parent.children && Array.isArray(parent.children) ) { - parent.children.splice(index + 1, 0, ogCard); + // 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) →

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

유지 후 OG 카드 삽입 + const linkText = aTag.children?.find((c: any) => c.type === 'text')?.value ?? ''; + const isUrlOnlyLink = linkText === href; + if (isUrlOnlyLink) { + parent.children.splice(index, 1, ogCard); + } else { + parent.children.splice(index + 1, 0, ogCard); + } } else return; } } @@ -330,7 +338,7 @@ export const createOpenGraph = (href: string, data: OGData) => { target: '_blank', rel: 'noopener noreferrer', className: - 'border-border bg-card hover:border-primary/40 hover:bg-muted/50 flex overflow-hidden rounded-xl border transition-colors', + 'border-border bg-card hover:border-primary/40 hover:bg-muted/50 flex overflow-hidden rounded-xl border transition-colors mb-4', }, children: cardChildren, }; From af5e34502977365ab84286acb333c13400db8303 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 15 Mar 2026 14:37:22 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20md=20=EB=AC=B8=EB=B2=95=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EB=8B=A4=EB=A5=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/utils/rehypeUtils.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/lib/utils/rehypeUtils.ts b/app/lib/utils/rehypeUtils.ts index 4a82520..26a8ddf 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -225,7 +225,8 @@ export const renderOpenGraph = ( ) { // 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) →

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

유지 후 OG 카드 삽입 - const linkText = aTag.children?.find((c: any) => c.type === 'text')?.value ?? ''; + const linkText = + aTag.children?.find((c: any) => c.type === 'text')?.value ?? ''; const isUrlOnlyLink = linkText === href; if (isUrlOnlyLink) { parent.children.splice(index, 1, ogCard); @@ -312,8 +313,9 @@ export const createOpenGraph = (href: string, data: OGData) => { type: 'element', tagName: 'div', properties: { - className: 'relative hidden aspect-video shrink-0 sm:block', - style: 'width: 120px', + className: + 'relative hidden aspect-video shrink-0 sm:block rounded-r-xl !rounded-l-none', + style: 'width: 160px', }, children: [ { @@ -322,7 +324,8 @@ export const createOpenGraph = (href: string, data: OGData) => { properties: { src: image, alt: title ?? '', - className: 'h-full !w-full object-cover !m-0 !p-0 !max-w-none', + className: + '!h-full !w-full object-cover !m-0 !p-0 !max-w-none !rounded-l-none', }, children: [], }, @@ -338,7 +341,7 @@ export const createOpenGraph = (href: string, data: OGData) => { target: '_blank', rel: 'noopener noreferrer', className: - 'border-border bg-card hover:border-primary/40 hover:bg-muted/50 flex overflow-hidden rounded-xl border transition-colors mb-4', + 'border-border bg-white dark:bg-neutral-800 hover:border-white/90 hover:bg-neutral-800/10 flex overflow-hidden rounded-xl border transition-colors mb-4 h-[112px]', }, children: cardChildren, }; From a5b68cda92a44e2aef5faee8f9c36868f81e3c34 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 15 Mar 2026 14:57:37 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20skeleton=20ui=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/detail/PostBody.tsx | 6 +- app/lib/utils/rehypeUtils.ts | 79 +++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/app/entities/post/detail/PostBody.tsx b/app/entities/post/detail/PostBody.tsx index 82572f4..d658760 100644 --- a/app/entities/post/detail/PostBody.tsx +++ b/app/entities/post/detail/PostBody.tsx @@ -37,7 +37,11 @@ const PostBody = ({ content, tags, loading }: Props) => { ); const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay(); - const [ogDataMap, setOgDataMap] = useState>({}); + const [ogDataMap, setOgDataMap] = useState< + Record + >(() => + Object.fromEntries(extractInternalLinks(content).map((href) => [href, undefined])) + ); useEffect(() => { const links = extractInternalLinks(content); diff --git a/app/lib/utils/rehypeUtils.ts b/app/lib/utils/rehypeUtils.ts index 26a8ddf..9ae8fca 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -202,7 +202,7 @@ export const renderOpenGraph = ( node: any, index?: number, parent?: Element, - ogDataMap?: Record + ogDataMap?: Record ) => { if (node.type === 'element' && node.tagName === 'p' && node.children) { const aTag = node.children.find( @@ -215,8 +215,18 @@ export const renderOpenGraph = ( href && (href.startsWith('/') || href.startsWith('http')); if (isOGTarget && ogDataMap && href in ogDataMap) { const data = ogDataMap[href]; - if (!data || (!data.title && !data.description)) return; - const ogCard = createOpenGraph(href, data); + + // null = 에러 → 원본 링크 유지 + if (data === null) return; + + const card = + data === undefined + ? createOGSkeleton() + : !data.title && !data.description + ? null + : createOpenGraph(href, data); + if (!card) return; + if ( index !== undefined && parent && @@ -224,20 +234,77 @@ export const renderOpenGraph = ( Array.isArray(parent.children) ) { // 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) →

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

유지 후 OG 카드 삽입 + // 커스텀 텍스트인 경우([text](url)) →

유지 후 카드 삽입 const linkText = aTag.children?.find((c: any) => c.type === 'text')?.value ?? ''; const isUrlOnlyLink = linkText === href; if (isUrlOnlyLink) { - parent.children.splice(index, 1, ogCard); + parent.children.splice(index, 1, card); } else { - parent.children.splice(index + 1, 0, ogCard); + parent.children.splice(index + 1, 0, card); } } else return; } } }; +/** + * OG 카드 로딩 중 스켈레톤 노드 생성 + */ +export const createOGSkeleton = () => { + const skeletonBox = (className: string) => ({ + type: 'element', + tagName: 'div', + properties: { + className: `rounded bg-gray-200/80 dark:bg-neutral-700/80 duration-100 ${className}`, + }, + children: [], + }); + + return { + type: 'element', + tagName: 'div', + properties: { + className: + 'border-border bg-card flex overflow-hidden rounded-xl border mb-4 h-[112px] animate-pulse', + }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { + className: + 'flex min-w-0 flex-1 flex-col justify-center gap-2 px-4', + }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { className: 'flex items-center gap-1.5' }, + children: [ + skeletonBox('h-3.5 w-3.5'), + skeletonBox('h-3 w-24'), + ], + }, + skeletonBox('h-4 w-3/4'), + skeletonBox('h-3 w-full'), + skeletonBox('h-3 w-2/3'), + ], + }, + { + type: 'element', + tagName: 'div', + properties: { + className: + 'relative hidden shrink-0 sm:block rounded-r-xl bg-gray-200/80 dark:bg-neutral-700/80', + style: 'width: 160px', + }, + children: [], + }, + ], + }; +}; + /** * Open Graph 카드 노드 생성 */ From 0c1fbe54e85c22a647503c61e76c303978fff203 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 15 Mar 2026 15:28:24 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20OGLinkCard=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/detail/OgLinkCard.tsx | 130 +++++++++++ app/entities/post/detail/PostBody.tsx | 31 +-- app/lib/utils/rehypeUtils.ts | 283 +++--------------------- 3 files changed, 163 insertions(+), 281 deletions(-) create mode 100644 app/entities/post/detail/OgLinkCard.tsx diff --git a/app/entities/post/detail/OgLinkCard.tsx b/app/entities/post/detail/OgLinkCard.tsx new file mode 100644 index 0000000..7ab2b23 --- /dev/null +++ b/app/entities/post/detail/OgLinkCard.tsx @@ -0,0 +1,130 @@ +'use client'; +import { useEffect, useState } from 'react'; +import Skeleton from '@/app/entities/common/Skeleton/Skeleton'; +import Image from 'next/image'; + +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 d658760..6204292 100644 --- a/app/entities/post/detail/PostBody.tsx +++ b/app/entities/post/detail/PostBody.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +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'; @@ -14,9 +15,6 @@ import { renderYoutubeEmbed, renderOpenGraph, createImageClickHandler, - extractInternalLinks, - fetchOGData, - OGData, } from '../../../lib/utils/rehypeUtils'; interface Props { @@ -37,24 +35,7 @@ const PostBody = ({ content, tags, loading }: Props) => { ); const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay(); - const [ogDataMap, setOgDataMap] = useState< - Record - >(() => - Object.fromEntries(extractInternalLinks(content).map((href) => [href, undefined])) - ); - - useEffect(() => { - const links = extractInternalLinks(content); - if (links.length === 0) return; - Promise.all( - links.map(async (href) => { - const data = await fetchOGData(href); - return [href, data] as [string, OGData | null]; - }) - ).then((results) => setOgDataMap(Object.fromEntries(results))); - }, [content]); - // 이미지 클릭 핸들러 생성 const addImageClickHandler = createImageClickHandler( setSelectedImage, setOpenImageBox @@ -63,7 +44,7 @@ const PostBody = ({ content, tags, loading }: Props) => { return (
{loading ? ( @@ -95,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, parent as Element | undefined, ogDataMap); + 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 9ae8fca..f9cede1 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -4,56 +4,6 @@ import { SelectedImage } from '../../entities/post/detail/PostBody'; -export interface OGData { - url: string; - title: string | null; - description: string | null; - image: string | null; - siteName: string | null; - favicon: string | null; - hostname: string; -} - -/** - * 마크다운에서 OG 카드로 변환할 링크(내부/외부) 추출 - */ -export const extractInternalLinks = (markdown: string): string[] => { - const links: string[] = []; - - // [text](url) 형식 — 내부(/로 시작) 또는 외부(http(s)://) URL - const mdLinkRegex = /\[([^\]]*)\]\(((?:https?:\/\/|\/)[^)]+)\)/g; - let match; - while ((match = mdLinkRegex.exec(markdown)) !== null) { - links.push(match[2]); - } - - // 마크다운 링크 문법 밖의 bare URL (ex. 단독으로 쓴 https://...) - const bareUrlRegex = /(?)"]+/g; - while ((match = bareUrlRegex.exec(markdown)) !== null) { - links.push(match[0]); - } - - return Array.from(new Set(links)); -}; - -/** - * /api/opengraph를 통해 OG 데이터 fetch - */ -export 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; - } -}; - /** * aside 태그의 첫 번째 텍스트 노드를 emoji span으로 래핑 */ @@ -88,14 +38,6 @@ export const addDescriptionUnderImage = ( parent?: Element ) => { if (node.type === 'element' && node.tagName === 'img') { - const parentEl = parent as any; - const isInsideOGCard = - (parentEl?.tagName === 'a' && - parentEl?.properties?.className?.includes('rounded-xl')) || - (parentEl?.tagName === 'div' && - parentEl?.properties?.className?.includes('aspect-video')); - if (isInsideOGCard) return null; - const altText = node.properties.alt; if (altText) { const descriptionNode = { @@ -196,13 +138,13 @@ export const createYoutubeIframe = ( }; /** - * 내부 링크를 Open Graph 카드로 변환 + * 링크를 감지하고 marker 노드로 변환 + * 실제 렌더링은 PostBody의 components prop에서 OgLinkCard 컴포넌트가 담당 */ export const renderOpenGraph = ( node: any, index?: number, - parent?: Element, - ogDataMap?: Record + parent?: Element ) => { if (node.type === 'element' && node.tagName === 'p' && node.children) { const aTag = node.children.find( @@ -211,207 +153,32 @@ export const renderOpenGraph = ( if (!aTag) return; const href = aTag.properties?.href; - const isOGTarget = - href && (href.startsWith('/') || href.startsWith('http')); - if (isOGTarget && ogDataMap && href in ogDataMap) { - const data = ogDataMap[href]; - - // null = 에러 → 원본 링크 유지 - if (data === null) return; - - const card = - data === undefined - ? createOGSkeleton() - : !data.title && !data.description - ? null - : createOpenGraph(href, data); - if (!card) return; - - if ( - index !== undefined && - parent && - 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, card); - } else { - parent.children.splice(index + 1, 0, card); - } - } else return; - } - } -}; - -/** - * OG 카드 로딩 중 스켈레톤 노드 생성 - */ -export const createOGSkeleton = () => { - const skeletonBox = (className: string) => ({ - type: 'element', - tagName: 'div', - properties: { - className: `rounded bg-gray-200/80 dark:bg-neutral-700/80 duration-100 ${className}`, - }, - children: [], - }); - - return { - type: 'element', - tagName: 'div', - properties: { - className: - 'border-border bg-card flex overflow-hidden rounded-xl border mb-4 h-[112px] animate-pulse', - }, - children: [ - { - type: 'element', - tagName: 'div', - properties: { - className: - 'flex min-w-0 flex-1 flex-col justify-center gap-2 px-4', - }, - children: [ - { - type: 'element', - tagName: 'div', - properties: { className: 'flex items-center gap-1.5' }, - children: [ - skeletonBox('h-3.5 w-3.5'), - skeletonBox('h-3 w-24'), - ], - }, - skeletonBox('h-4 w-3/4'), - skeletonBox('h-3 w-full'), - skeletonBox('h-3 w-2/3'), - ], - }, - { - type: 'element', - tagName: 'div', - properties: { - className: - 'relative hidden shrink-0 sm:block rounded-r-xl bg-gray-200/80 dark:bg-neutral-700/80', - style: 'width: 160px', - }, - children: [], - }, - ], - }; -}; - -/** - * Open Graph 카드 노드 생성 - */ -export const createOpenGraph = (href: string, data: OGData) => { - const { title, description, image, favicon, siteName, hostname } = data; - const siteLabel = siteName || hostname || ''; + if (!href || !(href.startsWith('/') || href.startsWith('http'))) return; - const siteInfoChildren: any[] = []; - if (favicon) { - siteInfoChildren.push({ + const ogNode = { type: 'element', - tagName: 'img', - properties: { - src: favicon, - alt: '', - width: '14', - height: '14', - className: 'shrink-0 rounded-sm', - }, + tagName: 'ogcard', + properties: { href }, children: [], - }); - } - siteInfoChildren.push({ - type: 'element', - tagName: 'span', - properties: { className: 'text-muted-foreground truncate text-xs' }, - children: [{ type: 'text', value: siteLabel }], - }); - - const textChildren: any[] = [ - { - type: 'element', - tagName: 'div', - properties: { className: 'flex items-center gap-1.5 h-4' }, - children: siteInfoChildren, - }, - ]; - if (title) { - textChildren.push({ - type: 'element', - tagName: 'p', - properties: { - className: - 'text-foreground line-clamp-1 text-sm font-semibold leading-snug !mb-1', - }, - children: [{ type: 'text', value: title }], - }); - } - if (description) { - textChildren.push({ - type: 'element', - tagName: 'p', - properties: { - className: - 'text-muted-foreground line-clamp-2 text-xs leading-relaxed !mb-0', - }, - children: [{ type: 'text', value: description }], - }); - } - - const cardChildren: any[] = [ - { - type: 'element', - tagName: 'div', - properties: { - className: 'flex min-w-0 flex-1 flex-col justify-center gap-0 px-4', - }, - children: textChildren, - }, - ]; - if (image) { - cardChildren.push({ - type: 'element', - tagName: 'div', - properties: { - className: - 'relative hidden aspect-video shrink-0 sm:block rounded-r-xl !rounded-l-none', - style: 'width: 160px', - }, - children: [ - { - type: 'element', - tagName: 'img', - properties: { - src: image, - alt: title ?? '', - className: - '!h-full !w-full object-cover !m-0 !p-0 !max-w-none !rounded-l-none', - }, - 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); + } + } } - - return { - type: 'element', - tagName: 'a', - properties: { - href, - target: '_blank', - rel: 'noopener noreferrer', - className: - 'border-border bg-white dark:bg-neutral-800 hover:border-white/90 hover:bg-neutral-800/10 flex overflow-hidden rounded-xl border transition-colors mb-4 h-[112px]', - }, - children: cardChildren, - }; }; /** From 71e9ccd06e5aee536c7606d8975be8ec76297e2e Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 15 Mar 2026 15:56:09 +0900 Subject: [PATCH 7/7] fix: lint --- app/entities/post/detail/OgLinkCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/entities/post/detail/OgLinkCard.tsx b/app/entities/post/detail/OgLinkCard.tsx index 7ab2b23..2e1f8b4 100644 --- a/app/entities/post/detail/OgLinkCard.tsx +++ b/app/entities/post/detail/OgLinkCard.tsx @@ -1,7 +1,7 @@ 'use client'; +import Image from 'next/image'; import { useEffect, useState } from 'react'; import Skeleton from '@/app/entities/common/Skeleton/Skeleton'; -import Image from 'next/image'; interface OGData { url: string;