Staging failed on boot with 42P01, relation "payload.users" does not exist. The schema was empty because no migration existed, and the adapter cannot create tables itself: db-postgres/connect.js gates push on NODE_ENV !== 'production', so it is inert in a deployed container regardless of config. The generated migration is schema-qualified to "payload" throughout but does not create that schema — schemaName says where tables go, it does not create anything. It only worked against the throwaway database used to generate it because the schema was created there by hand, so every real environment would have failed on the first statement. CREATE SCHEMA IF NOT EXISTS is hand-added at the top of up(), which makes it exactly the kind of edit a regeneration discards silently; a test asserts it is present and ordered before the first CREATE TABLE. payload-types.ts is now committed rather than ignored. Ignoring it meant CI typechecked against looser types than a developer with a generated copy, which is how a Record<string, unknown> cast passed CI and then failed locally the moment the file appeared. The post page uses the generated Post and Media types instead, and narrows heroImage rather than asserting it, since the field is an id at shallow depth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
177 lines
5.3 KiB
TypeScript
177 lines
5.3 KiB
TypeScript
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<string, unknown> } }) => (
|
|
<CalloutBlock
|
|
tone={String(node.fields.tone ?? 'caveat')}
|
|
body={String(node.fields.body ?? '')}
|
|
/>
|
|
),
|
|
},
|
|
});
|
|
|
|
/**
|
|
* 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<Metadata> {
|
|
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 (
|
|
<article className={styles.page}>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
|
|
<nav className={styles.crumb}>
|
|
<Link href="/blog" className={styles.link}>Blog</Link>
|
|
</nav>
|
|
|
|
<h1 className={styles.heading}>{summary.title}</h1>
|
|
|
|
<p className={styles.byline}>
|
|
By <Link href="/about" className={styles.link}>Tudor</Link>
|
|
{' · '}
|
|
<time dateTime={summary.publishedAt}>
|
|
{new Date(summary.publishedAt).toLocaleDateString('en-GB', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
})}
|
|
</time>
|
|
</p>
|
|
|
|
{/*
|
|
A plain <img>, not next/image: Payload already generated the sized
|
|
derivatives on upload (Media's imageSizes), so routing it through the
|
|
optimizer would resize an image that is already the right size.
|
|
*/}
|
|
{hero?.url && (
|
|
<img
|
|
className={styles.hero}
|
|
src={hero.url}
|
|
alt={hero.alt ?? ''}
|
|
width={hero.width ?? undefined}
|
|
height={hero.height ?? undefined}
|
|
/>
|
|
)}
|
|
|
|
<div className={styles.prose}>
|
|
<RichText data={post.content} converters={calloutConverters} />
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|