From 0a959abcb3f409ce2c7c169559db31ecd2bacd1e Mon Sep 17 00:00:00 2001 From: libr Date: Sun, 28 Dec 2025 09:12:20 +0800 Subject: [PATCH] og --- src/components/PageHead.astro | 25 ++++- src/components/PostHead.astro | 34 +++--- src/components/notion/Image.astro | 96 ++++++++++++++--- src/lib/data-utils.ts | 1 - src/pages/api/og/[slug].ts | 169 ++++++++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 33 deletions(-) create mode 100644 src/pages/api/og/[slug].ts diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro index 7983fde..6341026 100644 --- a/src/components/PageHead.astro +++ b/src/components/PageHead.astro @@ -5,32 +5,47 @@ interface Props { title?: string description?: string noindex?: boolean + image?: string } const { title = SITE.title, description = SITE.description, noindex = false, + image: imageProp, } = Astro.props -const image = new URL('/static/1200x630.png', Astro.site) +const siteUrl = Astro.site ?? new URL(SITE.href) +const defaultImagePath = '/static/1200x630.png' +const ogImageUrl = new URL(imageProp ?? defaultImagePath, siteUrl).toString() +const ogImageWidth = imageProp ? undefined : 1200 +const ogImageHeight = imageProp ? undefined : 630 +const ogImageType = imageProp ? undefined : 'image/png' +const canonicalUrl = Astro.url --- {`${title} | ${SITE.title}`} - + {noindex && } - + +{ogImageWidth && ( + +)} +{ogImageHeight && ( + +)} +{ogImageType && } - + - + diff --git a/src/components/PostHead.astro b/src/components/PostHead.astro index a35c101..356a98e 100644 --- a/src/components/PostHead.astro +++ b/src/components/PostHead.astro @@ -11,40 +11,48 @@ const { post } = Astro.props const title = post.data.title || SITE.title const description = post.data.description || SITE.description -const fallbackOgImage = new URL('/static/1200x630.png', Astro.site).toString() +const siteUrl = Astro.site ?? new URL(SITE.href) const author = post.data.authors && post.data.authors.length > 0 ? post.data.authors.join(', ') : SITE.author -const heroImage = post.data.banner ?? post.data.image -const heroImageUrl = - typeof heroImage === 'string' - ? heroImage - : heroImage?.src - ? `${SITE.href}${heroImage.src}` - : fallbackOgImage +const ogImage = { + url: new URL(`/api/og/${encodeURIComponent(post.id)}`, siteUrl).toString(), + width: 1200, + height: 630, + type: 'image/svg+xml', +} +const canonicalUrl = Astro.url --- {`${title} | ${SITE.title}`} - + {isSubpost(post.id) && } - + - +{ogImage.width && ( + +)} +{ogImage.height && ( + +)} +{ogImage.type && } + - + + - + diff --git a/src/components/notion/Image.astro b/src/components/notion/Image.astro index 1c8146c..e9921b6 100644 --- a/src/components/notion/Image.astro +++ b/src/components/notion/Image.astro @@ -4,6 +4,7 @@ import * as interfaces from '../../lib/interfaces' import { filePath } from '../../lib/blog-helpers' import Caption from './Caption.astro' import fslightbox from 'fslightbox' + export interface Props { block: interfaces.Block } @@ -20,19 +21,33 @@ if (block.Image.External) { } else if (block.Image.File?.Url) { image = filePath(new URL(block.Image.File.Url)) } + +const altText = block.Image.Caption?.map(c => c.Text.Content).join('') || 'Image' ---
{ image && ( -
-
+
+
{ENABLE_LIGHTBOX ? ( - - Image in a image block + + {altText} ) : ( - Image in a image block + {altText} )}
@@ -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 ` + + + + + + + + + + + + + + + + + ${escapeXml(siteHost)} + + ${titleSpans} + + ${ + descriptionLines.length + ? ` + ${descSpans} + ` + : '' + } + ${escapeXml(meta)} +` +} + +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', + }, + }) +}