Skip to content

Commit f1a714f

Browse files
Merge pull request #84 from ShipFriend0516/feature/skeleton-opengraph
feat: 오픈그래프 카드 추가
2 parents 486cba0 + 71e9ccd commit f1a714f

File tree

4 files changed

+254
-88
lines changed

4 files changed

+254
-88
lines changed

app/api/opengraph/route.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// HTML 파일로 부터 메타데이터 파싱
2+
const getMeta = (html: string, prop: string): string | null => {
3+
const patterns = [
4+
new RegExp(
5+
`<meta[^>]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']*)["']`,
6+
'i'
7+
),
8+
new RegExp(
9+
`<meta[^>]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${prop}["']`,
10+
'i'
11+
),
12+
];
13+
for (const pattern of patterns) {
14+
const match = html.match(pattern);
15+
if (match?.[1]?.trim()) return match[1].trim();
16+
}
17+
return null;
18+
};
19+
20+
// Open Graph API 엔드포인트
21+
export const GET = async (request: Request) => {
22+
const { searchParams } = new URL(request.url);
23+
const url = searchParams.get('url');
24+
25+
if (!url) return Response.json({ error: 'url required' }, { status: 400 });
26+
27+
try {
28+
new URL(url);
29+
} catch {
30+
return Response.json({ error: 'invalid url' }, { status: 400 });
31+
}
32+
33+
try {
34+
const res = await fetch(url, {
35+
headers: {
36+
'User-Agent':
37+
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
38+
Accept: 'text/html,application/xhtml+xml',
39+
},
40+
signal: AbortSignal.timeout(5000),
41+
});
42+
43+
if (!res.ok)
44+
return Response.json({ error: 'fetch failed' }, { status: 502 });
45+
46+
const html = await res.text();
47+
const urlObj = new URL(url);
48+
49+
const title =
50+
getMeta(html, 'og:title') ||
51+
getMeta(html, 'twitter:title') ||
52+
html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
53+
null;
54+
55+
const description =
56+
getMeta(html, 'og:description') ||
57+
getMeta(html, 'twitter:description') ||
58+
getMeta(html, 'description') ||
59+
null;
60+
61+
const image =
62+
getMeta(html, 'og:image') ||
63+
getMeta(html, 'twitter:image:src') ||
64+
getMeta(html, 'twitter:image') ||
65+
null;
66+
67+
const siteName = getMeta(html, 'og:site_name') || null;
68+
69+
const favicon = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`;
70+
71+
return Response.json(
72+
{
73+
url,
74+
title,
75+
description,
76+
image,
77+
siteName,
78+
favicon,
79+
hostname: urlObj.hostname,
80+
},
81+
{
82+
headers: {
83+
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
84+
},
85+
}
86+
);
87+
} catch {
88+
return Response.json({ error: 'fetch failed' }, { status: 502 });
89+
}
90+
};
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
'use client';
2+
import Image from 'next/image';
3+
import { useEffect, useState } from 'react';
4+
import Skeleton from '@/app/entities/common/Skeleton/Skeleton';
5+
6+
interface OGData {
7+
url: string;
8+
title: string | null;
9+
description: string | null;
10+
image: string | null;
11+
siteName: string | null;
12+
favicon: string | null;
13+
hostname: string;
14+
}
15+
16+
const fetchOGData = async (href: string): Promise<OGData | null> => {
17+
const baseUrl =
18+
process.env.NEXT_PUBLIC_DEPLOYMENT_URL || process.env.NEXT_PUBLIC_URL || '';
19+
const absoluteUrl = href.startsWith('/') ? `${baseUrl}${href}` : href;
20+
try {
21+
const res = await fetch(
22+
`/api/opengraph?url=${encodeURIComponent(absoluteUrl)}`
23+
);
24+
if (!res.ok) return null;
25+
return await res.json();
26+
} catch {
27+
return null;
28+
}
29+
};
30+
31+
const OgLinkCardSkeleton = () => (
32+
<div className="border-border bg-card mb-4 flex h-[112px] overflow-hidden rounded-xl border animate-pulse">
33+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-2 px-4">
34+
<div className="flex items-center gap-1.5">
35+
<Skeleton className="h-3.5 w-3.5" />
36+
<Skeleton className="h-3 w-24" />
37+
</div>
38+
<Skeleton className="h-4 w-3/4" />
39+
<Skeleton className="h-3 w-full" />
40+
<Skeleton className="h-3 w-2/3" />
41+
</div>
42+
<div
43+
className="relative hidden shrink-0 rounded-r-xl bg-gray-200/80 dark:bg-neutral-700/80 sm:block"
44+
style={{ width: '160px' }}
45+
/>
46+
</div>
47+
);
48+
49+
interface OgLinkCardProps {
50+
href: string;
51+
}
52+
53+
const OgLinkCard = ({ href }: OgLinkCardProps) => {
54+
const [data, setData] = useState<OGData | null | undefined>(undefined);
55+
56+
useEffect(() => {
57+
fetchOGData(href).then(setData);
58+
}, [href]);
59+
60+
if (data === undefined) return <OgLinkCardSkeleton />;
61+
62+
if (!data || (!data.title && !data.description)) {
63+
return (
64+
<a
65+
href={href}
66+
target="_blank"
67+
rel="noopener noreferrer"
68+
className="text-accent decoration-accent/30 hover:text-accent/80 my-4 block break-all underline underline-offset-2 transition-colors"
69+
>
70+
{href}
71+
</a>
72+
);
73+
}
74+
75+
const { title, description, image, favicon, siteName, hostname } = data;
76+
const siteLabel = siteName || hostname || '';
77+
78+
return (
79+
<a
80+
href={href}
81+
target="_blank"
82+
rel="noopener noreferrer"
83+
className="border-border dark:border-neutral-800/50 bg-white dark:bg-neutral-800 hover:border-white/90 hover:bg-neutral-800/10 mb-4 flex h-[112px] overflow-hidden rounded-xl border transition-colors"
84+
>
85+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-4 py-3">
86+
<div className="flex h-4 items-center gap-1.5">
87+
{favicon && (
88+
// eslint-disable-next-line @next/next/no-img-element
89+
<img
90+
src={favicon}
91+
alt=""
92+
width={14}
93+
height={14}
94+
className="shrink-0 rounded-sm"
95+
/>
96+
)}
97+
<span className="!text-muted-foreground truncate text-xs">
98+
{siteLabel}
99+
</span>
100+
</div>
101+
{title && (
102+
<p className="!text-foreground !mb-0 line-clamp-1 text-sm font-semibold leading-snug">
103+
{title}
104+
</p>
105+
)}
106+
{description && (
107+
<p className="!text-muted-foreground !mb-0 line-clamp-2 text-xs leading-relaxed">
108+
{description}
109+
</p>
110+
)}
111+
</div>
112+
{image && (
113+
<div
114+
className="relative hidden h-full shrink-0 sm:block !m-0"
115+
style={{ width: '160px' }}
116+
>
117+
<Image
118+
width={160}
119+
height={112}
120+
src={image}
121+
alt={title ?? ''}
122+
className="!h-full !w-full !max-w-none !m-0 !rounded-none object-cover"
123+
/>
124+
</div>
125+
)}
126+
</a>
127+
);
128+
};
129+
130+
export default OgLinkCard;

app/entities/post/detail/PostBody.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState } from 'react';
33
import LoadingIndicator from '@/app/entities/common/Loading/LoadingIndicator';
44
import ImageZoomOverlayContainer from '@/app/entities/common/Overlay/Image/ImageZoomOverlayContainer';
55
import Overlay from '@/app/entities/common/Overlay/Overlay';
6+
import OgLinkCard from '@/app/entities/post/detail/OgLinkCard';
67
import PostTOC from '@/app/entities/post/detail/PostTOC';
78
import TagBox from '@/app/entities/post/tags/TagBox';
89
import useOverlay from '@/app/hooks/common/useOverlay';
@@ -12,6 +13,7 @@ import {
1213
asideStyleRewrite,
1314
addDescriptionUnderImage,
1415
renderYoutubeEmbed,
16+
renderOpenGraph,
1517
createImageClickHandler,
1618
} from '../../../lib/utils/rehypeUtils';
1719

@@ -34,7 +36,6 @@ const PostBody = ({ content, tags, loading }: Props) => {
3436

3537
const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay();
3638

37-
// 이미지 클릭 핸들러 생성
3839
const addImageClickHandler = createImageClickHandler(
3940
setSelectedImage,
4041
setOpenImageBox
@@ -43,7 +44,7 @@ const PostBody = ({ content, tags, loading }: Props) => {
4344
return (
4445
<div
4546
className={
46-
'max-w-full post-body px-4 py-8 lg:py-16 min-h-[500px] relative '
47+
'max-w-full post-body px-4 py-8 lg:py-16 min-h-[500px] relative'
4748
}
4849
>
4950
{loading ? (
@@ -75,9 +76,13 @@ const PostBody = ({ content, tags, loading }: Props) => {
7576
wrapperElement={{
7677
'data-color-mode': theme,
7778
}}
79+
components={{
80+
ogcard: ({ href }: { href?: string }) =>
81+
href ? <OgLinkCard href={href} /> : null,
82+
} as any}
7883
rehypeRewrite={(node, index?, parent?) => {
7984
asideStyleRewrite(node);
80-
// renderOpenGraph(node, index || 0, parent as Element | undefined);
85+
renderOpenGraph(node, index, parent as Element | undefined);
8186
renderYoutubeEmbed(
8287
node,
8388
index || 0,

app/lib/utils/rehypeUtils.ts

Lines changed: 26 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ export const createYoutubeIframe = (
138138
};
139139

140140
/**
141-
* 내부 링크를 Open Graph 카드로 변환 (현재 비활성화)
141+
* 링크를 감지하고 <ogcard> marker 노드로 변환
142+
* 실제 렌더링은 PostBody의 components prop에서 OgLinkCard 컴포넌트가 담당
142143
*/
143144
export const renderOpenGraph = (
144145
node: any,
@@ -152,94 +153,34 @@ export const renderOpenGraph = (
152153
if (!aTag) return;
153154

154155
const href = aTag.properties?.href;
155-
if (href && href.startsWith('/')) {
156-
// 부모가 존재하고 children 배열이 있는 경우
157-
const opengraph = createOpenGraph(href);
158-
if (
159-
index !== undefined &&
160-
parent &&
161-
parent.children &&
162-
Array.isArray(parent.children)
163-
) {
164-
// 현재 a 태그 다음 위치에 div 삽입
165-
parent.children.splice(index + 1, 0, opengraph);
166-
} else return;
156+
if (!href || !(href.startsWith('/') || href.startsWith('http'))) return;
157+
158+
const ogNode = {
159+
type: 'element',
160+
tagName: 'ogcard',
161+
properties: { href },
162+
children: [],
163+
};
164+
165+
if (
166+
index !== undefined &&
167+
parent?.children &&
168+
Array.isArray(parent.children)
169+
) {
170+
// 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) → <p> 대체
171+
// 커스텀 텍스트인 경우([text](url)) → <p> 유지 후 카드 삽입
172+
const linkText =
173+
aTag.children?.find((c: any) => c.type === 'text')?.value ?? '';
174+
const isUrlOnlyLink = linkText === href;
175+
if (isUrlOnlyLink) {
176+
parent.children.splice(index, 1, ogNode);
177+
} else {
178+
parent.children.splice(index + 1, 0, ogNode);
179+
}
167180
}
168181
}
169182
};
170183

171-
/**
172-
* Open Graph 카드 노드 생성
173-
*/
174-
export const createOpenGraph = (href: string) => {
175-
return {
176-
type: 'element',
177-
tagName: 'a',
178-
properties: {
179-
className: 'open-graph',
180-
href: href,
181-
},
182-
children: [
183-
{
184-
type: 'element',
185-
tagName: 'img',
186-
properties: {
187-
src: `${href}`,
188-
alt: 'Open Graph Image',
189-
className: 'og-image',
190-
},
191-
children: [],
192-
},
193-
{
194-
type: 'element',
195-
tagName: 'div',
196-
properties: {
197-
className: 'og-container',
198-
},
199-
children: [
200-
{
201-
type: 'element',
202-
tagName: 'h4',
203-
properties: {
204-
className: 'og-title',
205-
},
206-
children: [
207-
{
208-
type: 'text',
209-
value: decodeURIComponent(href.split('/').pop()!).replaceAll(
210-
'-',
211-
' '
212-
),
213-
},
214-
],
215-
},
216-
{
217-
type: 'element',
218-
tagName: 'span',
219-
properties: {
220-
className: 'og-content',
221-
},
222-
children: [],
223-
},
224-
{
225-
type: 'element',
226-
tagName: 'span',
227-
properties: {
228-
className: 'og-domain',
229-
},
230-
children: [
231-
{
232-
type: 'text',
233-
value: '',
234-
},
235-
],
236-
},
237-
],
238-
},
239-
],
240-
};
241-
};
242-
243184
/**
244185
* 이미지 클릭 핸들러를 추가하는 함수 팩토리
245186
* @param setSelectedImage - 선택된 이미지 URL을 설정하는 함수

0 commit comments

Comments
 (0)