diff --git a/app/(frontend)/[slug]/opengraph-image.tsx b/app/(frontend)/[slug]/opengraph-image.tsx new file mode 100644 index 00000000..28edd37a --- /dev/null +++ b/app/(frontend)/[slug]/opengraph-image.tsx @@ -0,0 +1,92 @@ +import { notFound } from 'next/navigation' + +import { ImageResponse } from 'takumi-js/response' + +import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets' + +import { queryPageBySlug } from './page' + +export const alt = 'Page' +export const size = { + width: 1200, + height: 630, +} + +type Props = { + params: Promise<{ + slug?: string + }> +} + +export default async function Image({ params }: Props) { + const { slug = 'home' } = await params + const page = await queryPageBySlug(slug) + if (!page) notFound() + + const background = page.hero?.background + const title = page.title ?? 'Page' + + const backgroundStyle: React.CSSProperties = + background?.backgroundType === 'shader' && background.shader + ? { + background: + SHADER_PRESET_MAP[background.shader as keyof typeof SHADER_PRESET_MAP]?.gradient, + } + : {} + + // Pages' `hero.background.media` is a `hasMany: true` polymorphic upload + // field: it always resolves as an array of `{ relationTo, value }` wrapper + // objects, never a bare media object. `value` only holds the populated + // media document (with `.url`) once the relation has been resolved by the + // query this route reuses (`queryPageBySlug`, shared with `[slug]/page.tsx`). + // Mirrors the `isPopulated`/`toItems` pattern in `HeroMedia.tsx`. + const firstImageEntry = Array.isArray(background?.media) + ? background.media.find( + (entry) => + typeof entry === 'object' && + entry !== null && + entry.relationTo === 'images' && + typeof entry.value === 'object' && + entry.value !== null, + ) + : undefined + + const heroImageUrl = + background?.backgroundType === 'media' && + firstImageEntry && + typeof firstImageEntry.value === 'object' + ? (firstImageEntry.value?.url ?? undefined) + : undefined + + return new ImageResponse( +
+ {heroImageUrl && ( + // biome-ignore lint/performance/noImgElement: Takumi/OG image rendering requires a plain , not next/image + + )} +
+

{title}

+
, + { + width: 1200, + height: 630, + }, + ) +} diff --git a/app/(frontend)/[slug]/page.tsx b/app/(frontend)/[slug]/page.tsx index f541e5bc..a96b00d5 100644 --- a/app/(frontend)/[slug]/page.tsx +++ b/app/(frontend)/[slug]/page.tsx @@ -65,7 +65,7 @@ export default async function Page({ params }: PageProps) { {title && (
@@ -147,7 +147,7 @@ const queryDraftPageBySlug = async (slug: string) => { return (docs[0] as CollectionData) || null } -const queryPageBySlug = cache(async (slug: string) => { +export const queryPageBySlug = cache(async (slug: string) => { const { isEnabled: draft } = await draftMode() return draft ? queryDraftPageBySlug(slug) : queryPublishedPageBySlug(slug) }) diff --git a/app/(frontend)/blog/[slug]/opengraph-image.tsx b/app/(frontend)/blog/[slug]/opengraph-image.tsx new file mode 100644 index 00000000..1a17fa7d --- /dev/null +++ b/app/(frontend)/blog/[slug]/opengraph-image.tsx @@ -0,0 +1,38 @@ +import { notFound } from 'next/navigation' + +import { ImageResponse } from 'takumi-js/response' + +import { RESERVED_TOPIC_SLUGS } from '@/types/blog' + +import { queryPublishedTopicBySlug } from '../_shared/BlogListPage' + +export const alt = 'Blog topic' +export const size = { + width: 1200, + height: 630, +} + +type Props = { + params: Promise<{ + slug: string + }> +} + +export default async function Image({ params }: Props) { + const { slug } = await params + if (RESERVED_TOPIC_SLUGS.includes(slug)) notFound() + + const topic = await queryPublishedTopicBySlug(slug) + if (!topic) notFound() + + return new ImageResponse( +
+

Blog

+

{topic.title}

+
, + { + width: 1200, + height: 630, + }, + ) +} diff --git a/app/(frontend)/blog/_shared/BlogListPage.tsx b/app/(frontend)/blog/_shared/BlogListPage.tsx index ae27564e..7765a1ae 100644 --- a/app/(frontend)/blog/_shared/BlogListPage.tsx +++ b/app/(frontend)/blog/_shared/BlogListPage.tsx @@ -6,6 +6,7 @@ import { getPayload } from 'payload' import { format } from 'date-fns' +import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets' import { ImageMedia } from '@/components/ImageMedia' import { PageContainer } from '@/components/PageContainer' import { cn } from '@/lib/cn' @@ -86,8 +87,23 @@ const queryPublishedPosts = async ({ topicId, page }: { topicId?: string; page: const PostCard = ({ post }: { post: BlogPostData }) => { // populated upload values omit mimeType, so isMediaImage() can't be used — // the relation's target collection is the reliable discriminator + const background = post.hero?.background const heroImage = - post.heroImage?.relationTo === CollectionSlug.MediaImages ? post.heroImage.value : undefined + background?.backgroundType === 'media' && + typeof background.media === 'object' && + background.media !== null && + 'relationTo' in background.media && + background.media.relationTo === CollectionSlug.MediaImages + ? background.media.value + : undefined + // guarded like the OG image routes: `shader` is typed as the preset-key + // union but the value comes from the database, so a preset that has since + // been renamed or removed resolves to undefined — degrade to no background + // div rather than throwing and taking down the whole server-rendered list + const shaderGradient = + background?.backgroundType === 'shader' && typeof background.shader === 'string' + ? SHADER_PRESET_MAP[background.shader]?.gradient + : undefined return ( { ])} /> )} + {shaderGradient && ( +
+ )} ) } diff --git a/app/(frontend)/blog/opengraph-image.tsx b/app/(frontend)/blog/opengraph-image.tsx new file mode 100644 index 00000000..39d5c43c --- /dev/null +++ b/app/(frontend)/blog/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { ImageResponse } from 'takumi-js/response' + +export const alt = 'Blog' +export const size = { + width: 1200, + height: 630, +} + +export default async function Image() { + return new ImageResponse( +
+

Blog

+
, + { + width: 1200, + height: 630, + }, + ) +} diff --git a/app/(frontend)/blog/page/[page]/opengraph-image.tsx b/app/(frontend)/blog/page/[page]/opengraph-image.tsx new file mode 100644 index 00000000..fddc6580 --- /dev/null +++ b/app/(frontend)/blog/page/[page]/opengraph-image.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from 'takumi-js/response' + +export const alt = 'Blog' +export const size = { + width: 1200, + height: 630, +} + +type Props = { + params: Promise<{ + page: string + }> +} + +export default async function Image({ params }: Props) { + const { page } = await params + + return new ImageResponse( +
+

Blog

+

Page {page}

+
, + { + width: 1200, + height: 630, + }, + ) +} diff --git a/app/(frontend)/blog/post/[slug]/opengraph-image.tsx b/app/(frontend)/blog/post/[slug]/opengraph-image.tsx new file mode 100644 index 00000000..fb60cbd8 --- /dev/null +++ b/app/(frontend)/blog/post/[slug]/opengraph-image.tsx @@ -0,0 +1,85 @@ +import { notFound } from 'next/navigation' + +import { ImageResponse } from 'takumi-js/response' + +import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets' + +import { queryPostBySlug } from './page' + +export const alt = 'Blog post' +export const size = { + width: 1200, + height: 630, +} + +type Props = { + params: Promise<{ + slug?: string + }> +} + +export default async function Image({ params }: Props) { + const { slug } = await params + const post = await queryPostBySlug({ + slug: slug ?? '', + }) + if (!post) notFound() + + const background = post.hero?.background + const title = post.title ?? 'Blog post' + + const backgroundStyle: React.CSSProperties = + background?.backgroundType === 'shader' && background.shader + ? { + background: + SHADER_PRESET_MAP[background.shader as keyof typeof SHADER_PRESET_MAP]?.gradient, + } + : {} + + // BlogPosts' `hero.background.media` is a `hasMany: false` polymorphic + // upload field: it resolves as a single `{ relationTo, value }` wrapper + // object (never an array), where `value` holds the populated media + // document (with `.url`) once resolved by `queryPostBySlug`. Mirrors the + // `isPopulated` discriminator in `HeroMedia.tsx`, minus the array step. + const heroImageUrl = + background?.backgroundType === 'media' && + background.media && + typeof background.media === 'object' && + 'relationTo' in background.media && + background.media.relationTo === 'images' && + typeof background.media.value === 'object' + ? (background.media.value?.url ?? undefined) + : undefined + + return new ImageResponse( +
+ {heroImageUrl && ( + // biome-ignore lint/performance/noImgElement: Takumi/OG image rendering requires a plain , not next/image + + )} +
+

{title}

+
, + { + width: 1200, + height: 630, + }, + ) +} diff --git a/app/(frontend)/blog/post/[slug]/page.tsx b/app/(frontend)/blog/post/[slug]/page.tsx index fcd832c2..bc8ba0a7 100644 --- a/app/(frontend)/blog/post/[slug]/page.tsx +++ b/app/(frontend)/blog/post/[slug]/page.tsx @@ -23,6 +23,8 @@ import { resolveRelations } from '@/lib/resolveRelation' import { highlightRichText } from '@/lib/shiki/highlightRichText' import { CollectionData, CollectionSlug } from '@/types/collections' +import { size } from './opengraph-image' + export async function generateStaticParams() { const payload = await getPayload({ config, @@ -62,11 +64,10 @@ export default async function Page({ params: paramsPromise }: PageProps) { }) if (!post) return notFound() - const { title, content, heroImage, createdAt, updatedAt, topics } = post + const { title, content, hero, createdAt, updatedAt, topics } = post const baseUrl = process.env.SERVER_URL || 'https://danielheene.de' const postUrl = `${baseUrl}/posts/${slug}` - const heroData = heroImage?.value const headings = extractHeadings(content) // Shiki is server-only, so code blocks are highlighted here and handed to // RichText, which renders on the client @@ -78,14 +79,12 @@ export default async function Page({ params: paramsPromise }: PageProps) { .filter((value) => typeof value === 'object' && value !== null) const primaryTopic = topicList[0] - const heroImageData = heroData?.url - ? { - url: new URL(new URL(heroData.url).pathname, baseUrl).toString(), - width: heroData.width || undefined, - height: heroData.height || undefined, - alt: heroData.alt || title, - } - : undefined + const heroImageData = { + url: `${baseUrl}/blog/post/${slug}/opengraph-image`, + width: size.width, + height: size.height, + alt: title, + } // // // Extract keywords from tags // const postTags = tags @@ -136,7 +135,7 @@ export default async function Page({ params: paramsPromise }: PageProps) {
{/* A post carries at most one hero image, so this never becomes a carousel. */} - +