mirror of
https://github.com/lbr77/blog-astro.git
synced 2026-04-08 16:11:56 +00:00
og
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
169
src/pages/api/og/[slug].ts
Normal 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, '&')
|
||||
.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) =>
|
||||
`<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',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user