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 type { Post, Media } from '@/payload-types'; 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; /** * heroImage is `number | Media | null`: an id when the query is shallow, the * populated document at depth 1. Both pages query at depth 1, but narrowing * rather than asserting keeps it correct if that ever changes. */ function heroOf(post: Post): Media | null { return typeof post.heroImage === 'object' && post.heroImage !== null ? post.heroImage : 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: Post) { return { title: post.title, slug: post.slug, excerpt: post.excerpt, publishedAt: 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 = heroOf(post); return { title: post.title, description: post.excerpt, alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) }, openGraph: { type: 'article', title: post.title, description: post.excerpt, url: absoluteUrl(`/blog/${post.slug}`), publishedTime: 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); const hero = heroOf(post); const jsonLd = { '@context': 'https://schema.org', '@graph': [ blogPostingJsonLd(summary), breadcrumbJsonLd(summary), personJsonLd(), organizationJsonLd(), ], }; return (