diff --git a/src/app/api/bookmark/route.ts b/src/app/api/bookmark/route.ts new file mode 100644 index 0000000..98a6c55 --- /dev/null +++ b/src/app/api/bookmark/route.ts @@ -0,0 +1,53 @@ +import * as cheerio from 'cheerio'; +import { NextResponse } from 'next/server'; + +interface MetaData { + title: string; + description: string; + image: string; + favicon: string; + domain: string; +} + +async function getMetadata(url: string): Promise { + try { + const response = await fetch(url); + const html = await response.text(); + const $ = cheerio.load(html); + const domain = new URL(url).hostname; + + return { + title: $('meta[property="og:title"]').attr('content') || $('title').text() || domain, + description: + $('meta[property="og:description"]').attr('content') || + $('meta[name="description"]').attr('content') || + '', + image: $('meta[property="og:image"]').attr('content') || '', + favicon: + $('link[rel="icon"]').attr('href') || + $('link[rel="shortcut icon"]').attr('href') || + `https://${domain}/favicon.ico`, + domain, + }; + } catch (error) { + return { + title: url, + description: '', + image: '', + favicon: '', + domain: new URL(url).hostname, + }; + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'URL is required' }, { status: 400 }); + } + + const metadata = await getMetadata(url); + return NextResponse.json(metadata); +} diff --git a/src/styles/post.scss b/src/styles/post.scss index a93e2e3..a5e1433 100644 --- a/src/styles/post.scss +++ b/src/styles/post.scss @@ -135,20 +135,40 @@ @apply my-4 border rounded-lg overflow-hidden transition-all hover:shadow-md; .bookmark-link { - @apply no-underline block p-4; + @apply no-underline block; } .bookmark-content { - @apply flex items-center gap-3; + @apply flex items-start gap-4 p-4; } - .bookmark-icon { - @apply text-xl; + .bookmark-info { + @apply flex-1 min-w-0; } - .bookmark-url { + .bookmark-title { + @apply text-lg font-semibold mb-1 truncate; + } + + .bookmark-description { @apply text-sm text-neutral-600 dark:text-neutral-400 - truncate hover:text-blue-500 dark:hover:text-blue-400; + line-clamp-2 mb-2; + } + + .bookmark-domain { + @apply flex items-center gap-2 text-sm text-neutral-500; + } + + .bookmark-favicon { + @apply w-4 h-4; + } + + .bookmark-thumbnail { + @apply w-24 h-24 flex-shrink-0; + } + + .bookmark-image { + @apply w-full h-full object-cover; } } } diff --git a/src/utils/rehypeBookmark.ts b/src/utils/rehypeBookmark.ts index 67e3c83..c659fc6 100644 --- a/src/utils/rehypeBookmark.ts +++ b/src/utils/rehypeBookmark.ts @@ -1,10 +1,25 @@ import type { Element } from 'hast'; import { visit } from 'unist-util-visit'; +async function fetchMetadata(url: string) { + try { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; + const apiUrl = `${baseUrl}/api/bookmark?url=${encodeURIComponent(url)}`; + + const response = await fetch(apiUrl); + console.log('response', response); + return await response.json(); + } catch (error) { + console.error('fetchMetadata error', error); + return null; + } +} + export function rehypeBookmark() { - return (tree: any) => { + return async (tree: any) => { + const promises: Promise[] = []; + visit(tree, 'element', (node: Element) => { - //

bookmark

구조 확인 if ( node.tagName === 'p' && node.children?.[0]?.type === 'element' && @@ -13,45 +28,93 @@ export function rehypeBookmark() { node.children[0].children[0].value === 'bookmark' ) { const link = node.children[0]; - const href = link.properties?.href; + const href = link.properties?.href as string; + + promises.push( + (async () => { + const metadata = await fetchMetadata(href); + if (!metadata) return; - // 북마크 컴포넌트로 변환 - node.tagName = 'div'; - node.properties = { className: ['bookmark-card'] }; - node.children = [ - { - type: 'element', - tagName: 'a', - properties: { - href, - target: '_blank', - rel: 'noopener noreferrer', - className: ['bookmark-link'], - }, - children: [ + node.tagName = 'div'; + node.properties = { className: ['bookmark-card'] }; + node.children = [ { type: 'element', - tagName: 'div', - properties: { className: ['bookmark-content'] }, + tagName: 'a', + properties: { + href, + target: '_blank', + rel: 'noopener noreferrer', + className: ['bookmark-link'], + }, children: [ { type: 'element', tagName: 'div', - properties: { className: ['bookmark-icon'] }, - children: [{ type: 'text', value: '🔖' }], - }, - { - type: 'element', - tagName: 'div', - properties: { className: ['bookmark-url'] }, - children: [{ type: 'text', value: String(href) }], + properties: { className: ['bookmark-content'] }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { className: ['bookmark-info'] }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { className: ['bookmark-title'] }, + children: [{ type: 'text', value: metadata.title }], + }, + { + type: 'element', + tagName: 'div', + properties: { className: ['bookmark-description'] }, + children: [{ type: 'text', value: metadata.description }], + }, + { + type: 'element', + tagName: 'div', + properties: { className: ['bookmark-domain'] }, + children: [ + { + type: 'element', + tagName: 'img', + properties: { + src: metadata.favicon, + alt: '', + className: ['bookmark-favicon'], + }, + }, + { type: 'text', value: metadata.domain }, + ], + }, + ], + }, + metadata.image && { + type: 'element', + tagName: 'div', + properties: { className: ['bookmark-thumbnail'] }, + children: [ + { + type: 'element', + tagName: 'img', + properties: { + src: metadata.image, + alt: metadata.title, + className: ['bookmark-image'], + }, + }, + ], + }, + ].filter(Boolean), }, ], }, - ], - }, - ]; + ]; + })(), + ); } }); + + await Promise.all(promises); }; }