Files
blog-nextjs/app/api/og/route.tsx
gbanyan 1d4cfe773c fix: load CJK fonts in OG image route and prefer feature_image for Twitter cards
OG images rendered without fonts caused blank/tofu text for Chinese titles,
breaking Twitter card previews. Now loads Noto Sans TC with in-memory cache.
Blog posts also prefer feature_image when available for social card images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:14:12 +08:00

199 lines
5.7 KiB
TypeScript

import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';
const fontCache = new Map<string, ArrayBuffer>();
async function loadFont(url: string): Promise<ArrayBuffer> {
const cached = fontCache.get(url);
if (cached) return cached;
const res = await fetch(url);
const data = await res.arrayBuffer();
fontCache.set(url, data);
return data;
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Get parameters
const title = searchParams.get('title') || 'Blog Post';
const description = searchParams.get('description') || '';
const tags = searchParams.get('tags')?.split(',').slice(0, 3) || [];
// Load CJK font for Chinese text rendering
const fontData = await loadFont(
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@latest/chinese-traditional-400-normal.woff'
);
const fontBoldData = await loadFont(
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@latest/chinese-traditional-700-normal.woff'
);
const imageResponse = new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
fontFamily: '"Noto Sans TC", sans-serif',
backgroundColor: '#0f172a',
backgroundImage: 'radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)',
backgroundSize: '100px 100px',
padding: '80px',
}}
>
{/* Header with gradient */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
}}
>
<div
style={{
width: '8px',
height: '60px',
background: 'linear-gradient(135deg, #3b82f6, #60a5fa)',
borderRadius: '4px',
}}
/>
<div
style={{
fontSize: '32px',
fontWeight: 600,
color: '#f8fafc',
letterSpacing: '-0.02em',
}}
>
</div>
</div>
{/* Main content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '900px',
}}
>
{/* Title */}
<div
style={{
fontSize: '72px',
fontWeight: 700,
color: '#f8fafc',
lineHeight: 1.1,
letterSpacing: '-0.03em',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</div>
{/* Description */}
{description && (
<div
style={{
fontSize: '28px',
color: '#cbd5e1',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{description}
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div
style={{
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
}}
>
{tags.map((tag, i) => (
<div
key={i}
style={{
backgroundColor: '#1e293b',
color: '#94a3b8',
padding: '8px 20px',
borderRadius: '20px',
fontSize: '20px',
border: '1px solid #334155',
}}
>
#{tag.trim()}
</div>
))}
</div>
)}
</div>
{/* Footer with accent line */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
width: '100%',
}}
>
<div
style={{
flex: 1,
height: '2px',
background: 'linear-gradient(90deg, #3b82f6, transparent)',
}}
/>
<div
style={{
fontSize: '24px',
color: '#64748b',
}}
>
gbanyan.net
</div>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{ name: 'Noto Sans TC', data: fontData, weight: 400 as const, style: 'normal' as const },
{ name: 'Noto Sans TC', data: fontBoldData, weight: 700 as const, style: 'normal' as const },
],
}
);
// Wrap response with cache headers for OG images (cache for 1 hour)
return new Response(imageResponse.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
},
});
} catch (e: any) {
console.error('Error generating OG image:', e);
return new Response(`Failed to generate image: ${e.message}`, {
status: 500,
});
}
}