import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { Link } from "@/i18n/navigation"; import { getLocale, getTranslations, setRequestLocale } from "next-intl/server"; import { ArrowLeft, ArrowRight, CalendarDays, Clock } from "lucide-react"; import { routing } from "@/i18n/routing"; import { Container, Section } from "@/components/ui/layout"; import { Badge } from "@/components/ui/badge"; import { ReadingProgress } from "@/components/motion/reading-progress"; import { TableOfContents } from "@/components/post/table-of-contents"; import { getAdjacentPosts, getPostBySlug } from "@/lib/posts"; import { compilePostMdx } from "@/lib/mdx"; import { extractToc } from "@/lib/toc"; import { formatDate, toDateTimeAttribute } from "@/lib/utils"; import { siteConfig } from "@/lib/site-config"; /** * 文章详情页。 * * 数据流:从数据库读取正文 → 编译 MDX(Shiki 语法高亮) * → 提取 TOC → 渲染。 * * 整页为 Server Component:正文与高亮结果都是服务端渲染的 HTML, * 只有阅读进度条与目录高亮这两个交互点进入客户端 bundle。 */ type PostPageProps = { params: Promise<{ locale: string; slug: string }>; }; /** * 渲染策略:按需动态渲染,不做 ISR 缓存。 * * ## 为什么这里不能用 `revalidate` * * 详情页需要根据文章是否存在返回**真正的 HTTP 404**。但一旦启用 ISR, * Next.js 会把该路由视为可静态生成:首次请求时先以 200 开始渲染, * 之后 `notFound()` 只替换页面内容,**状态码已经发出去了**, * 结果就是「不存在/草稿的文章 → 内容显示 404、状态码却是 200」。 * * 这会让搜索引擎把不存在的 URL 当成有效页面收录,属于 SEO 缺陷。 * 因此详情页保持动态渲染(内容本身仍很快,SQL 走 slug 唯一索引)。 * * 首页、关于页、sitemap 不存在这个问题,仍使用 ISR 缓存。 */ export const dynamic = "force-dynamic"; export async function generateMetadata({ params, }: PostPageProps): Promise { const { locale, slug } = await params; const post = await getPostBySlug(slug); if (!post) { return { title: "文章不存在" }; } const description = post.description ?? post.summary; const url = `${siteConfig.url}/${locale}/posts/${post.slug}`; return { title: post.title, description, keywords: post.tags, authors: [{ name: siteConfig.author.name }], alternates: { canonical: `/${locale}/posts/${post.slug}`, languages: Object.fromEntries( routing.locales.map((item) => [item, `/${item}/posts/${post.slug}`]), ), }, openGraph: { type: "article", url, title: post.title, description, publishedTime: new Date(`${post.date}T00:00:00Z`).toISOString(), authors: [siteConfig.author.name], tags: post.tags, images: [{ url: post.cover ?? siteConfig.ogImage }], }, twitter: { card: "summary_large_image", title: post.title, description, images: [post.cover ?? siteConfig.ogImage], }, }; } export default async function PostPage({ params }: PostPageProps) { const { locale, slug } = await params; setRequestLocale(locale); const post = await getPostBySlug(slug); // 草稿与不存在的 slug 一律 404 if (!post) { notFound(); } const t = await getTranslations("Post"); const currentLocale = await getLocale(); const { content } = await compilePostMdx(post.content); const toc = extractToc(post.content); const { previous, next } = await getAdjacentPosts(post.slug); // 结构化数据,帮助搜索引擎理解文章内容 const jsonLd = { "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, description: post.description ?? post.summary, datePublished: new Date(`${post.date}T00:00:00Z`).toISOString(), author: { "@type": "Person", name: siteConfig.author.name, }, keywords: post.tags?.join(", "), inLanguage: locale, mainEntityOfPage: { "@type": "WebPage", "@id": `${siteConfig.url}/${locale}/posts/${post.slug}`, }, }; return ( <>