diff --git a/next.config.ts b/next.config.ts index 15874d65..edb87a89 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,6 +23,22 @@ export default withNextra({ locales: ['en', 'ko', 'ja'], defaultLocale: 'en', }, + // Skip i18n for API routes + skipTrailingSlashRedirect: true, + async rewrites() { + return { + beforeFiles: [ + // API 경로는 i18n 리다이렉트를 건너뛰도록 처리 + { + source: '/api/:path*', + destination: '/api/:path*', + locale: false, + }, + ], + afterFiles: [], + fallback: [], + }; + }, // Configure redirects for Previous Version Documentation async redirects() { return [ diff --git a/public/og-background.png b/public/og-background.png new file mode 100644 index 00000000..fffc885b Binary files /dev/null and b/public/og-background.png differ diff --git a/src/app/[lang]/[[...mdxPath]]/page.tsx b/src/app/[lang]/[[...mdxPath]]/page.tsx index 6469901a..7a0b7978 100644 --- a/src/app/[lang]/[[...mdxPath]]/page.tsx +++ b/src/app/[lang]/[[...mdxPath]]/page.tsx @@ -67,17 +67,38 @@ export async function generateMetadata(props: { }): Promise { const params = await props.params; const { metadata } = await importPage(params.mdxPath, params.lang || 'en'); - + // Generate canonical URL const canonicalUrl = await getCanonicalUrl(params); - - // Add canonical URL to metadata, merging with existing alternates if any + + // Generate OG image URL with query parameters + const title = metadata.title ? encodeURIComponent(String(metadata.title)) : ''; + const description = metadata.description ? encodeURIComponent(String(metadata.description)) : ''; + const ogImagePath = `/api/og?lang=${params.lang}&title=${title}&description=${description}`; + + // Add canonical URL and OG image to metadata return { ...metadata, alternates: { ...metadata.alternates, canonical: canonicalUrl, }, + openGraph: { + ...metadata.openGraph, + images: [ + { + url: ogImagePath, + width: 1200, + height: 630, + alt: metadata.title ? String(metadata.title) : 'QueryPie Documentation', + }, + ], + }, + twitter: { + ...metadata.twitter, + card: 'summary_large_image', + images: [ogImagePath], + }, }; } diff --git a/src/pages/api/og.tsx b/src/pages/api/og.tsx new file mode 100644 index 00000000..83e0dd2e --- /dev/null +++ b/src/pages/api/og.tsx @@ -0,0 +1,138 @@ +import { ImageResponse } from 'next/og'; +import type { NextRequest } from 'next/server'; + +export const config = { + runtime: 'edge', +}; + +const size = { + width: 1200, + height: 630, +}; + +// 원격 폰트 URL (TTF 형식 - ImageResponse에서 지원) +// Google Fonts에서 Noto Sans 사용 (영어/한국어/일본어 지원) +const FONT_URLS = { + // Noto Sans - 라틴 문자용 + notoSans: + 'https://fonts.gstatic.com/s/notosans/v36/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A-9a6Vc.ttf', + // Noto Sans JP - 일본어/한국어 CJK 문자용 + notoSansJP: + 'https://fonts.gstatic.com/s/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEi75vY0rw-oME.ttf', +}; + +/** + * 원격 폰트를 로드합니다. + */ +async function loadFont(url: string): Promise { + try { + const response = await fetch(url); + if (response.ok) { + return await response.arrayBuffer(); + } + } catch { + // 폰트 로드 실패 + } + return null; +} + +export default async function handler(req: NextRequest) { + const { searchParams, origin } = new URL(req.url); + const title = searchParams.get('title') || 'QueryPie Documentation'; + const description = searchParams.get('description') || ''; + + // 제목이 너무 길면 자르기 + const maxTitleLength = 50; + const displayTitle = + title.length > maxTitleLength ? title.substring(0, maxTitleLength) + '...' : title; + + // 설명이 너무 길면 자르기 + const maxDescLength = 120; + const displayDescription = + description.length > maxDescLength ? description.substring(0, maxDescLength) + '...' : description; + + // 리소스 병렬 로드 (배경 이미지, 폰트) + const [backgroundImageData, notoSansFont, notoSansJPFont] = await Promise.all([ + fetch(`${origin}/og-background.png`) + .then((res) => (res.ok ? res.arrayBuffer() : null)) + .catch(() => null), + loadFont(FONT_URLS.notoSans), + loadFont(FONT_URLS.notoSansJP), + ]); + + // 배경 스타일 + const backgroundStyle = backgroundImageData + ? { + backgroundImage: `url(data:image/png;base64,${Buffer.from(backgroundImageData).toString('base64')})`, + backgroundSize: 'cover' as const, + backgroundPosition: 'center' as const, + } + : { backgroundColor: '#1a1a2e' }; + + // 폰트 설정 + const fonts: { name: string; data: ArrayBuffer; style: 'normal' | 'italic' }[] = []; + if (notoSansFont) { + fonts.push({ name: 'Noto Sans', data: notoSansFont, style: 'normal' }); + } + if (notoSansJPFont) { + fonts.push({ name: 'Noto Sans JP', data: notoSansJPFont, style: 'normal' }); + } + + const fontFamily = fonts.length > 0 ? "'Noto Sans', 'Noto Sans JP', sans-serif" : undefined; + + return new ImageResponse( + ( +
+ {/* 제목 영역 */} +
+
+ {displayTitle} +
+
+ + {/* 설명 영역 */} + {displayDescription && ( +
+ {displayDescription} +
+ )} +
+ ), + { + ...size, + fonts: fonts.length > 0 ? fonts : undefined, + } + ); +} diff --git a/src/proxy.ts b/src/proxy.ts index 3fce1e91..9edc3069 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -9,6 +9,7 @@ const SKIP_MIDDLEWARE_URIS = new Map([ ['_next', 'Handled by Next.js'], ['robots.txt', 'Handled by route handler'], ['.well-known', 'Handled by route handler'], + ['api', 'Handled by API route handler'], // slugs[0] - Served in public ['BingSiteAuth.xml', 'Served in public'], ['google7b73baf7a3209e6f.html', 'Served in public'], diff --git a/vercel.json b/vercel.json index d9a1d333..d33ebe97 100644 --- a/vercel.json +++ b/vercel.json @@ -9,5 +9,15 @@ "trailingSlash": false, "git": { "deploymentEnabled": false - } -} \ No newline at end of file + }, + "rewrites": [ + { + "source": "/api/:path*", + "destination": "/api/:path*" + }, + { + "source": "/og-image", + "destination": "/og-image" + } + ] +}