import { cache } from 'react'; import type { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { RichText } from '@payloadcms/richtext-lexical/react'; import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react'; import { getCachedPayload } from '@/lib/payload'; import { absoluteUrl } from '@/lib/site'; import { blogPostingJsonLd, breadcrumbJsonLd, personJsonLd, organizationJsonLd, } from '@/lib/jsonld'; import { CalloutBlock } from '@/components/blog/CalloutBlock'; import styles from './Post.module.css'; /* * ISR. Unlike the index, this route has a dynamic param and no * generateStaticParams, so there is nothing for the build to prerender: each * post is generated on first request and cached until the collection's * afterChange hook revalidates it. That hook is what makes an edit to an * already-published post appear immediately. */ export const revalidate = 3600; interface HeroImage { url?: string | null; alt?: string | null; width?: number | null; height?: number | null; } /** * Spreads the default converters and adds the one custom block. * * Without the spread, every default node type โ€” paragraphs, headings, links โ€” * loses its renderer and the post body comes out empty. */ const calloutConverters: JSXConvertersFunction = ({ defaultConverters }) => ({ ...defaultConverters, blocks: { // Annotated because the generic block converter cannot infer a custom // block's field shape; String() guards the values regardless. callout: ({ node }: { node: { fields: Record } }) => ( ), }, }); /** * Wrapped in React's cache() because Next calls generateMetadata and the page * component separately for the same request โ€” without it, every post view runs * this query against Postgres twice. cache() dedupes within a single request * only, so it never serves one visitor's request from another's. */ const findPost = cache(async (slug: string) => { const payload = await getCachedPayload(); const { docs } = await payload.find({ collection: 'posts', where: { slug: { equals: slug }, _status: { equals: 'published' } }, limit: 1, depth: 1, }); return docs[0] ?? null; }); function summarise(post: Record) { return { title: String(post.title), slug: String(post.slug), excerpt: String(post.excerpt), publishedAt: String(post.publishedAt), }; } export async function generateMetadata( { params }: { params: Promise<{ slug: string }> }, ): Promise { const { slug } = await params; const post = await findPost(slug); if (!post) return { title: 'Not found' }; const hero = post.heroImage as HeroImage | null | undefined; return { title: String(post.title), description: String(post.excerpt), alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) }, openGraph: { type: 'article', title: String(post.title), description: String(post.excerpt), url: absoluteUrl(`/blog/${post.slug}`), publishedTime: String(post.publishedAt), // A post with a hero image shares that; one without falls through to the // generated share card at app/opengraph-image.tsx. ...(hero?.url ? { images: [{ url: hero.url }] } : {}), }, }; } export default async function PostPage( { params }: { params: Promise<{ slug: string }> }, ) { const { slug } = await params; const post = await findPost(slug); if (!post) notFound(); const summary = summarise(post as Record); const hero = post.heroImage as HeroImage | null | undefined; const jsonLd = { '@context': 'https://schema.org', '@graph': [ blogPostingJsonLd(summary), breadcrumbJsonLd(summary), personJsonLd(), organizationJsonLd(), ], }; return (