This commit is contained in:
2025-12-28 09:12:20 +08:00
parent c86006381b
commit 0a959abcb3
5 changed files with 292 additions and 33 deletions

View File

@@ -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>{`${title} | ${SITE.title}`}</title>
<meta name="description" content={description} />
<link rel="canonical" href={SITE.href} />
<link rel="canonical" href={canonicalUrl} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:alt" content={title} />
{ogImageWidth && (
<meta property="og:image:width" content={String(ogImageWidth)} />
)}
{ogImageHeight && (
<meta property="og:image:height" content={String(ogImageHeight)} />
)}
{ogImageType && <meta property="og:image:type" content={ogImageType} />}
<meta property="og:type" content="website" />
<meta property="og:locale" content={SITE.locale} />
<meta property="og:site_name" content={SITE.title} />
<meta property="og:url" content={Astro.url} />
<meta property="og:url" content={canonicalUrl} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={title} />
<meta name="twitter:card" content="summary_large_image" />

View File

@@ -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>{`${title} | ${SITE.title}`}</title>
<meta name="title" content={`${title} | ${SITE.title}`} />
<meta name="description" content={description} />
<link rel="canonical" href={SITE.href} />
<link rel="canonical" href={canonicalUrl} />
{isSubpost(post.id) && <meta name="robots" content="noindex" />}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={heroImageUrl} />
<meta property="og:image" content={ogImage.url} />
<meta property="og:image:alt" content={title} />
<meta property="og:type" content="website" />
{ogImage.width && (
<meta property="og:image:width" content={String(ogImage.width)} />
)}
{ogImage.height && (
<meta property="og:image:height" content={String(ogImage.height)} />
)}
{ogImage.type && <meta property="og:image:type" content={ogImage.type} />}
<meta property="og:type" content="article" />
<meta property="og:locale" content={SITE.locale} />
<meta property="og:site_name" content={SITE.title} />
<meta property="og:url" content={Astro.url} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:author" content={author} />
<meta property="article:published_time" content={post.data.date.toISOString()} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="twitter:image" content={heroImageUrl} />
<meta name="twitter:image" content={ogImage.url} />
<meta name="twitter:image:alt" content={title} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content={author} />

View File

@@ -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'
---
<figure class="image">
{
image && (
<div>
<div>
<div class="image-wrapper">
<div class="image-container">
{ENABLE_LIGHTBOX ? (
<a data-fslightbox href={image} data-type="image">
<img src={image} alt="Image in a image block" loading="lazy" />
<a data-fslightbox href={image} data-type="image" class="lightbox-link">
<img
src={image}
alt={altText}
loading="lazy"
decoding="async"
class="image-content"
/>
</a>
) : (
<img src={image} alt="Image in a image block" loading="lazy" />
<img
src={image}
alt={altText}
loading="lazy"
decoding="async"
class="image-content"
/>
)}
</div>
<Caption richTexts={block.Image.Caption} />
@@ -44,15 +59,68 @@ if (block.Image.External) {
<style>
.image {
display: flex;
margin: 0.2rem auto 0;
}
.image > div {
margin: 0 auto;
}
.image > div > div {
}
.image > div > div img {
display: block;
margin: 1rem auto;
max-width: 100%;
}
.image-wrapper {
margin: 0 auto;
max-width: 100%;
}
.image-container {
position: relative;
overflow: hidden;
border-radius: 0.5rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.image-container:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.lightbox-link {
display: block;
cursor: zoom-in;
}
.image-content {
display: block;
max-width: 100%;
height: auto;
width: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.image-content[loading="lazy"] {
background: linear-gradient(90deg,
hsl(var(--muted)) 0%,
hsl(var(--muted) / 0.8) 50%,
hsl(var(--muted)) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@media (max-width: 768px) {
.image {
margin: 0.75rem auto;
}
.image-container:hover {
transform: none;
box-shadow: none;
}
}
</style>

View File

@@ -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',

169
src/pages/api/og/[slug].ts Normal file
View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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) =>
`<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>`
}
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',
},
})
}