This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,250 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user