mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-08 16:11:56 +00:00
feat: replace og endpoint with vercel og
This commit is contained in:
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user