+
{ENABLE_LIGHTBOX ? (
-
-
+
+
) : (
-

+

)}
@@ -44,15 +59,68 @@ if (block.Image.External) {
diff --git a/src/lib/data-utils.ts b/src/lib/data-utils.ts
index 4c4021f..bbff789 100644
--- a/src/lib/data-utils.ts
+++ b/src/lib/data-utils.ts
@@ -78,7 +78,6 @@ export function normalizePost(post: NotionPost): CollectionEntry<'blog'> {
const id = post.slug || post.id
const banner = (post as any).banner || null
const image = banner
-
return {
id,
collection: 'blog',
diff --git a/src/pages/api/og/[slug].ts b/src/pages/api/og/[slug].ts
new file mode 100644
index 0000000..cb3b369
--- /dev/null
+++ b/src/pages/api/og/[slug].ts
@@ -0,0 +1,169 @@
+import type { APIRoute } from 'astro'
+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, ''')
+}
+
+function truncateChars(input: string, maxChars: number) {
+ const chars = Array.from(input)
+ if (chars.length <= maxChars) return input
+ if (maxChars <= 3) return chars.slice(0, maxChars).join('')
+ return chars.slice(0, maxChars - 3).join('') + '...'
+}
+
+function wrapText(input: string, maxChars: number, maxLines: number) {
+ const text = input.replace(/\s+/g, ' ').trim()
+ if (!text) return []
+ const chars = Array.from(text)
+ const lines: string[] = []
+ let current: string[] = []
+
+ for (const ch of chars) {
+ if (current.length >= maxChars) {
+ lines.push(current.join(''))
+ current = []
+ if (lines.length >= maxLines) break
+ }
+ current.push(ch)
+ }
+
+ if (lines.length < maxLines && current.length > 0) {
+ lines.push(current.join(''))
+ }
+
+ const maxTotal = maxChars * maxLines
+ if (chars.length > maxTotal && lines.length > 0) {
+ lines[lines.length - 1] = truncateChars(
+ lines[lines.length - 1],
+ maxChars,
+ )
+ }
+
+ 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) =>
+ `
${escapeXml(line)}`,
+ )
+ .join('')
+ const descSpans = descriptionLines
+ .map(
+ (line, index) =>
+ `
${escapeXml(line)}`,
+ )
+ .join('')
+
+ return `
+
`
+}
+
+export const GET: APIRoute = async ({ params }) => {
+ let slug = params.slug ?? ''
+ if (slug) {
+ try {
+ slug = decodeURIComponent(slug)
+ } catch {
+ // Keep the raw slug if decoding fails.
+ }
+ }
+ const remotePost = slug ? await fetchRemotePost(slug) : null
+ const post = remotePost ? normalizePost(remotePost.post) : null
+ const title = post?.data?.title || SITE.title
+ const description = post?.data?.description || SITE.description
+ const dateValue = post?.data?.date ?? null
+ const meta = dateValue
+ ? `${SITE.title} | ${formatDate(dateValue)}`
+ : SITE.title
+ const siteHost = new URL(SITE.href).host
+
+ const svg = buildSvg({
+ title,
+ description,
+ meta,
+ siteHost,
+ })
+
+ return new Response(svg, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
+ 'Cache-Control':
+ 'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
+ },
+ })
+}