Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/api/opengraph/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// HTML 파일로 부터 메타데이터 파싱
const getMeta = (html: string, prop: string): string | null => {
const patterns = [
new RegExp(
`<meta[^>]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']*)["']`,
'i'
),
new RegExp(
`<meta[^>]+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[^>]*>([^<]+)<\/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 });
}
};
130 changes: 130 additions & 0 deletions app/entities/post/detail/OgLinkCard.tsx
Original file line number Diff line number Diff line change
@@ -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<OGData | null> => {
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 = () => (
<div className="border-border bg-card mb-4 flex h-[112px] overflow-hidden rounded-xl border animate-pulse">
<div className="flex min-w-0 flex-1 flex-col justify-center gap-2 px-4">
<div className="flex items-center gap-1.5">
<Skeleton className="h-3.5 w-3.5" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
</div>
<div
className="relative hidden shrink-0 rounded-r-xl bg-gray-200/80 dark:bg-neutral-700/80 sm:block"
style={{ width: '160px' }}
/>
</div>
);

interface OgLinkCardProps {
href: string;
}

const OgLinkCard = ({ href }: OgLinkCardProps) => {
const [data, setData] = useState<OGData | null | undefined>(undefined);

useEffect(() => {
fetchOGData(href).then(setData);
}, [href]);

if (data === undefined) return <OgLinkCardSkeleton />;

if (!data || (!data.title && !data.description)) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent decoration-accent/30 hover:text-accent/80 my-4 block break-all underline underline-offset-2 transition-colors"
>
{href}
</a>
);
}

const { title, description, image, favicon, siteName, hostname } = data;
const siteLabel = siteName || hostname || '';

return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
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"
>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-4 py-3">
<div className="flex h-4 items-center gap-1.5">
{favicon && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={favicon}
alt=""
width={14}
height={14}
className="shrink-0 rounded-sm"
/>
)}
<span className="!text-muted-foreground truncate text-xs">
{siteLabel}
</span>
</div>
{title && (
<p className="!text-foreground !mb-0 line-clamp-1 text-sm font-semibold leading-snug">
{title}
</p>
)}
{description && (
<p className="!text-muted-foreground !mb-0 line-clamp-2 text-xs leading-relaxed">
{description}
</p>
)}
</div>
{image && (
<div
className="relative hidden h-full shrink-0 sm:block !m-0"
style={{ width: '160px' }}
>
<Image
width={160}
height={112}
src={image}
alt={title ?? ''}
className="!h-full !w-full !max-w-none !m-0 !rounded-none object-cover"
/>
</div>
)}
</a>
);
};

export default OgLinkCard;
11 changes: 8 additions & 3 deletions app/entities/post/detail/PostBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,6 +13,7 @@ import {
asideStyleRewrite,
addDescriptionUnderImage,
renderYoutubeEmbed,
renderOpenGraph,
createImageClickHandler,
} from '../../../lib/utils/rehypeUtils';

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

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

// 이미지 클릭 핸들러 생성
const addImageClickHandler = createImageClickHandler(
setSelectedImage,
setOpenImageBox
Expand All @@ -43,7 +44,7 @@ const PostBody = ({ content, tags, loading }: Props) => {
return (
<div
className={
'max-w-full post-body px-4 py-8 lg:py-16 min-h-[500px] relative '
'max-w-full post-body px-4 py-8 lg:py-16 min-h-[500px] relative'
}
>
{loading ? (
Expand Down Expand Up @@ -75,9 +76,13 @@ const PostBody = ({ content, tags, loading }: Props) => {
wrapperElement={{
'data-color-mode': theme,
}}
components={{
ogcard: ({ href }: { href?: string }) =>
href ? <OgLinkCard href={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,
Expand Down
111 changes: 26 additions & 85 deletions app/lib/utils/rehypeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export const createYoutubeIframe = (
};

/**
* 내부 링크를 Open Graph 카드로 변환 (현재 비활성화)
* 링크를 감지하고 <ogcard> marker 노드로 변환
* 실제 렌더링은 PostBody의 components prop에서 OgLinkCard 컴포넌트가 담당
*/
export const renderOpenGraph = (
node: any,
Expand All @@ -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)) → <p> 대체
// 커스텀 텍스트인 경우([text](url)) → <p> 유지 후 카드 삽입
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을 설정하는 함수
Expand Down
Loading