Files
app-261004/app/[locale]/posts/[slug]/page.tsx
T
2026-10-08 11:09:37 +08:00

251 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Metadata> {
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 (
<>
<ReadingProgress />
<script
type="application/ld+json"
// JSON.stringify 的输出已转义 < > &,可安全内联
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Section spacing="default">
<Container>
{/* 返回首页入口 */}
<Link
href="/"
className="inline-flex items-center gap-xs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
<ArrowLeft className="size-4" aria-hidden="true" />
{t("backHome")}
</Link>
<div className="mt-xl grid grid-cols-1 gap-xxl lg:grid-cols-[minmax(0,1fr)_200px]">
<article className="min-w-0">
{/* 文章头部 */}
<header className="flex flex-col gap-sm border-b border-hairline-soft pb-xl">
<div className="flex flex-wrap items-center gap-sm text-caption text-steel">
<span className="inline-flex items-center gap-[6px]">
<CalendarDays className="size-3.5" aria-hidden="true" />
<time dateTime={toDateTimeAttribute(post.date)}>
{formatDate(post.date, currentLocale)}
</time>
</span>
<span aria-hidden="true" className="text-muted">
·
</span>
<span className="inline-flex items-center gap-[6px]">
<Clock className="size-3.5" aria-hidden="true" />
{t("readingTime", { minutes: post.readingMinutes })}
</span>
</div>
<h1 className="text-heading-1 font-semibold text-ink text-balance-pretty">
{post.title}
</h1>
<p className="text-subtitle text-steel text-balance-pretty">
{post.summary}
</p>
{post.tags && post.tags.length > 0 ? (
<ul
aria-label={t("tags")}
className="mt-xs flex flex-wrap gap-xs"
>
{post.tags.map((tag) => (
<li key={tag}>
<Badge variant="tag">{tag}</Badge>
</li>
))}
</ul>
) : null}
</header>
{/* MDX 正文 */}
<div className="prose-blog mt-xl">{content}</div>
{/* 上一篇 / 下一篇 */}
{previous || next ? (
<nav
aria-label="文章导航"
className="mt-section grid grid-cols-1 gap-md border-t border-hairline-soft pt-xl sm:grid-cols-2"
>
{previous ? (
<Link
href={`/posts/${previous.slug}`}
className="group flex flex-col gap-xxs rounded-lg border border-hairline p-md transition-colors duration-150 hover:bg-surface-soft"
>
<span className="inline-flex items-center gap-xs text-caption text-steel">
<ArrowLeft className="size-3.5" aria-hidden="true" />
{t("previous")}
</span>
<span className="text-body-sm font-medium text-ink group-hover:text-brand-green-deep">
{previous.title}
</span>
</Link>
) : (
<span />
)}
{next ? (
<Link
href={`/posts/${next.slug}`}
className="group flex flex-col items-end gap-xxs rounded-lg border border-hairline p-md text-right transition-colors duration-150 hover:bg-surface-soft sm:col-start-2"
>
<span className="inline-flex items-center gap-xs text-caption text-steel">
{t("next")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</span>
<span className="text-body-sm font-medium text-ink group-hover:text-brand-green-deep">
{next.title}
</span>
</Link>
) : null}
</nav>
) : null}
</article>
{/* 右侧目录:移动端隐藏,lg 起显示为粘性侧栏 */}
{toc.length > 0 ? (
<aside className="hidden lg:block">
<div className="sticky top-[88px]">
<TableOfContents items={toc} />
</div>
</aside>
) : null}
</div>
</Container>
</Section>
</>
);
}