feat: replace og endpoint with vercel og

This commit is contained in:
2026-01-01 15:38:11 +08:00
parent ccbefff766
commit 39b32e6e3b
3 changed files with 239 additions and 109 deletions

View File

@@ -1,24 +1,18 @@
import type { APIRoute } from 'astro'
import { Resvg } from '@resvg/resvg-js'
import type { CSSProperties } from 'react'
import { createElement } from 'react'
import { ImageResponse } from '@vercel/og'
import { SITE } from '@/consts'
import { fetchRemotePost, normalizePost } from '@/lib/data-utils'
import { formatDate } from '@/lib/utils'
const WIDTH = 1200
const HEIGHT = 630
const PADDING_X = 80
const FONT_STACK =
'Geist, "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif'
function escapeXml(value: string) {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export const config = { runtime: 'edge' }
function truncateChars(input: string, maxChars: number) {
const chars = Array.from(input)
@@ -58,81 +52,119 @@ function wrapText(input: string, maxChars: number, maxLines: number) {
return lines
}
function buildSvg({
title,
description,
meta,
siteHost,
}: {
title: string
description: string
meta: string
siteHost: string
}) {
const titleLines = wrapText(title, 24, 2)
const descriptionLines = wrapText(description, 44, 3)
const titleLineHeight = 64
const descLineHeight = 34
const titleY = 220
const descY =
titleY + titleLines.length * titleLineHeight + (descriptionLines.length ? 18 : 0)
const metaY = HEIGHT - 72
const titleSpans = titleLines
.map(
(line, index) =>
`<tspan x="${PADDING_X}" dy="${
index === 0 ? 0 : titleLineHeight
}">${escapeXml(line)}</tspan>`,
)
.join('')
const descSpans = descriptionLines
.map(
(line, index) =>
`<tspan x="${PADDING_X}" dy="${
index === 0 ? 0 : descLineHeight
}">${escapeXml(line)}</tspan>`,
)
.join('')
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-label="${escapeXml(title)}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0b1020" />
<stop offset="100%" stop-color="#101a2b" />
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.5" />
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0.2" />
</linearGradient>
</defs>
<style>
.bg { fill: url(#bg); }
.eyebrow { font: 600 22px ${FONT_STACK}; letter-spacing: 0.08em; text-transform: uppercase; fill: rgba(248, 250, 252, 0.7); }
.title { font: 700 56px ${FONT_STACK}; fill: #f8fafc; }
.desc { font: 400 28px ${FONT_STACK}; fill: rgba(226, 232, 240, 0.85); }
.meta { font: 500 24px ${FONT_STACK}; fill: rgba(226, 232, 240, 0.8); }
</style>
<rect class="bg" width="${WIDTH}" height="${HEIGHT}" rx="32" />
<circle cx="${WIDTH - 120}" cy="120" r="140" fill="url(#accent)" />
<circle cx="${WIDTH - 40}" cy="${HEIGHT - 80}" r="120" fill="url(#accent)" />
<text class="eyebrow" x="${PADDING_X}" y="96">${escapeXml(siteHost)}</text>
<text class="title" x="${PADDING_X}" y="${titleY}">
${titleSpans}
</text>
${
descriptionLines.length
? `<text class="desc" x="${PADDING_X}" y="${descY}">
${descSpans}
</text>`
: ''
}
<text class="meta" x="${PADDING_X}" y="${metaY}">${escapeXml(meta)}</text>
</svg>`
const containerStyle: CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
padding: '48px',
color: '#0f172a',
fontFamily: FONT_STACK,
backgroundImage: 'linear-gradient(130deg, #fdf2f8 0%, #e0f2fe 60%, #f0fdf4 100%)',
overflow: 'hidden',
}
const cardStyle: CSSProperties = {
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '32px',
width: '100%',
maxWidth: '1040px',
padding: '64px',
borderRadius: '32px',
border: '1px solid rgba(226, 232, 240, 0.7)',
background:
'linear-gradient(120deg, rgba(255, 255, 255, 0.92), rgba(240, 253, 244, 0.95))',
boxShadow: '0 40px 120px rgba(148, 163, 184, 0.35)',
}
const gridStyle: CSSProperties = {
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(rgba(14, 116, 144, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(14, 116, 144, 0.08) 1px, transparent 1px)',
backgroundSize: '40px 40px',
borderRadius: '32px',
maskImage: 'radial-gradient(circle at center, rgba(255, 255, 255, 0.9), transparent 70%)',
pointerEvents: 'none',
}
const badgeStyle: CSSProperties = {
fontSize: 20,
fontWeight: 600,
letterSpacing: '0.25em',
textTransform: 'uppercase',
color: 'rgba(15, 23, 42, 0.75)',
display: 'flex',
alignItems: 'center',
gap: '10px',
}
const badgeDotStyle: CSSProperties = {
width: 10,
height: 10,
borderRadius: '999px',
background:
'radial-gradient(circle, rgba(236, 72, 153, 0.9), rgba(236, 72, 153, 0.3))',
boxShadow: '0 0 20px rgba(236, 72, 153, 0.4)',
}
const titleStyle: CSSProperties = {
fontSize: 64,
fontWeight: 700,
letterSpacing: '-0.02em',
lineHeight: 1.15,
color: '#0f172a',
whiteSpace: 'pre-line',
}
const descriptionStyle: CSSProperties = {
fontSize: 30,
lineHeight: 1.45,
color: 'rgba(15, 23, 42, 0.75)',
whiteSpace: 'pre-line',
}
const metaRowStyle: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: '1px solid rgba(148, 163, 184, 0.3)',
paddingTop: '28px',
marginTop: '8px',
}
const metaLeftStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '6px',
}
const metaLabelStyle: CSSProperties = {
fontSize: 18,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(15, 118, 110, 0.7)',
}
const metaValueStyle: CSSProperties = {
fontSize: 24,
fontWeight: 600,
color: '#0f172a',
}
const signatureStyle: CSSProperties = {
fontSize: 22,
fontWeight: 500,
color: 'rgba(14, 116, 144, 0.85)',
}
const wrapAsBlock = (value: string, maxChars: number, maxLines: number) =>
wrapText(value, maxChars, maxLines).join('\n')
export const GET: APIRoute = async ({ params }) => {
let slug = params.slug ?? ''
if (slug) {
@@ -151,29 +183,89 @@ export const GET: APIRoute = async ({ params }) => {
? `${SITE.title} | ${formatDate(dateValue)}`
: SITE.title
const siteHost = new URL(SITE.href).host
const formattedDate = dateValue ? formatDate(dateValue) : null
const titleBlock = wrapAsBlock(title, 24, 2)
const descriptionBlock = wrapAsBlock(description, 44, 3)
const svg = buildSvg({
title,
description,
meta,
const badge = createElement('div', { style: badgeStyle }, [
createElement('span', { style: badgeDotStyle }),
siteHost,
})
])
const png = new Resvg(svg, {
fitTo: {
mode: 'width',
value: WIDTH,
const cardChildren = [
createElement('div', { style: gridStyle }),
badge,
createElement('div', { style: titleStyle }, titleBlock),
]
if (descriptionBlock) {
cardChildren.push(
createElement('div', { style: descriptionStyle }, descriptionBlock),
)
}
const metaRow = createElement('div', { style: metaRowStyle }, [
createElement('div', { style: metaLeftStyle }, [
createElement('div', { style: metaLabelStyle }, 'PUBLISHED'),
createElement(
'div',
{ style: metaValueStyle },
formattedDate ?? 'Fresh dispatches',
),
]),
createElement(
'div',
{ style: signatureStyle },
formattedDate ? `${SITE.title} · ${siteHost}` : meta,
),
])
cardChildren.push(metaRow)
const accentRing = createElement('div', {
style: {
position: 'absolute',
width: 520,
height: 520,
borderRadius: '50%',
border: '1px solid rgba(236, 72, 153, 0.45)',
right: -120,
top: -140,
transform: 'rotate(25deg)',
opacity: 0.8,
},
})
.render()
.asPng()
return new Response(png, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control':
'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
const accentGlow = createElement('div', {
style: {
position: 'absolute',
width: 420,
height: 420,
borderRadius: '50%',
left: -120,
bottom: -160,
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.35), transparent 65%)',
filter: 'blur(2px)',
},
})
const card = createElement('div', { style: cardStyle }, cardChildren)
const root = createElement('div', { style: containerStyle }, [
accentGlow,
accentRing,
card,
])
return new ImageResponse(
root,
{
width: WIDTH,
height: HEIGHT,
headers: {
'Content-Type': 'image/png',
'Cache-Control':
'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
},
},
)
}