This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,234 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import {
|
||||
Briefcase,
|
||||
MapPin,
|
||||
Mail,
|
||||
Code2,
|
||||
Gauge,
|
||||
Palette,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { Container, Section } from "@/components/ui/layout";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { buttonClassName } from "@/components/ui/button-variants";
|
||||
import { Reveal } from "@/components/motion/reveal";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
/**
|
||||
* 关于页渲染策略:ISR 缓存 60 秒。
|
||||
* 路由段配置必须是字面量(Next.js 会静态分析)。
|
||||
*/
|
||||
export const revalidate = 60;
|
||||
|
||||
type AboutPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: AboutPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
alternates: { canonical: `/${locale}/about` },
|
||||
openGraph: {
|
||||
type: "profile",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
url: `${siteConfig.url}/${locale}/about`,
|
||||
images: [{ url: siteConfig.ogImage }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 关注方向 —— 图标仅为装饰,语义由文本承载 */
|
||||
const FOCUS_AREAS = [
|
||||
{
|
||||
icon: Gauge,
|
||||
title: "Web 性能",
|
||||
description:
|
||||
"关注核心网页指标、渲染路径与打包体积,把「快」当作可度量的工程目标而不是感觉。",
|
||||
},
|
||||
{
|
||||
icon: Code2,
|
||||
title: "开发者体验",
|
||||
description:
|
||||
"好的工具应该让正确的事情变简单。投入最多的地方是构建流程、类型系统与文档。",
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: "设计系统",
|
||||
description:
|
||||
"把设计规范翻译成可执行的代码约束,让一致性由类型系统保证,而不是靠代码评审。",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "工程化实践",
|
||||
description:
|
||||
"测试策略、CI/CD、可观测性——那些看不见但决定了项目能走多远的基建。",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations("About");
|
||||
const { author } = siteConfig;
|
||||
|
||||
const facts = [
|
||||
{ icon: Briefcase, label: t("role"), value: author.jobTitle },
|
||||
{ icon: MapPin, label: t("location"), value: author.location },
|
||||
{ icon: Mail, label: t("email"), value: author.email },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 页头 */}
|
||||
<Section
|
||||
spacing="sm"
|
||||
className="border-b border-hairline-soft bg-surface-soft"
|
||||
>
|
||||
<Container size="narrow">
|
||||
<p className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
|
||||
{t("eyebrow")}
|
||||
</p>
|
||||
<h1 className="mt-sm text-heading-1 font-semibold text-ink">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-md max-w-[640px] text-subtitle text-steel text-balance-pretty">
|
||||
{t("description")}
|
||||
</p>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<Section spacing="default">
|
||||
<Container size="narrow">
|
||||
<div className="grid grid-cols-1 gap-xxl lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
{/* 主栏:简介 + 关注方向 */}
|
||||
<div className="flex flex-col gap-section">
|
||||
<section aria-labelledby="intro-heading">
|
||||
<h2
|
||||
id="intro-heading"
|
||||
className="text-heading-3 font-semibold text-ink"
|
||||
>
|
||||
{t("introTitle")}
|
||||
</h2>
|
||||
|
||||
<div className="mt-md flex flex-col gap-md text-body-md leading-[1.75] text-slate">
|
||||
<p>{author.bio}</p>
|
||||
<p>
|
||||
这个博客用来记录我在日常工作中遇到的问题和解决思路。
|
||||
我倾向于写下那些「当时找不到答案、后来自己想明白了」的内容,
|
||||
因为这类经验对别人往往也最有价值。
|
||||
</p>
|
||||
<p>
|
||||
写作风格上,我更在意准确而不是完整:与其罗列所有可能性,
|
||||
不如把一条路径讲透,并说清楚它为什么成立、在什么情况下会失效。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="focus-heading">
|
||||
<h2
|
||||
id="focus-heading"
|
||||
className="text-heading-3 font-semibold text-ink"
|
||||
>
|
||||
{t("techTitle")}
|
||||
</h2>
|
||||
<p className="mt-xs text-body-sm text-steel">
|
||||
{t("techDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-lg grid grid-cols-1 gap-md sm:grid-cols-2">
|
||||
{FOCUS_AREAS.map((area, index) => (
|
||||
<Reveal key={area.title} delay={index * 0.05}>
|
||||
<Card className="flex h-full flex-col gap-xs" padding="md">
|
||||
<area.icon
|
||||
className="size-5 text-brand-green-deep"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-heading-5 font-semibold text-ink">
|
||||
{area.title}
|
||||
</h3>
|
||||
<p className="text-body-sm text-steel">
|
||||
{area.description}
|
||||
</p>
|
||||
</Card>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 侧栏:基本信息 + 联系方式 */}
|
||||
<aside className="flex flex-col gap-xl">
|
||||
<Card
|
||||
padding="md"
|
||||
variant="feature"
|
||||
className="flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-heading-5 font-semibold text-ink">
|
||||
基本信息
|
||||
</h2>
|
||||
|
||||
<dl className="flex flex-col gap-sm">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label} className="flex items-start gap-sm">
|
||||
<fact.icon
|
||||
className="mt-[2px] size-4 shrink-0 text-steel"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-caption text-steel">
|
||||
{fact.label}
|
||||
</dt>
|
||||
<dd className="break-words text-body-sm text-ink">
|
||||
{fact.value}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="flex flex-col gap-sm">
|
||||
<h2 className="text-heading-5 font-semibold text-ink">
|
||||
{t("contactTitle")}
|
||||
</h2>
|
||||
<p className="text-body-sm text-steel">
|
||||
{t("contactDescription")}
|
||||
</p>
|
||||
|
||||
<ul className="mt-xs flex flex-col gap-xs">
|
||||
{siteConfig.social.map((item) => (
|
||||
<li key={item.label}>
|
||||
<a
|
||||
href={item.href}
|
||||
target={
|
||||
item.href.startsWith("mailto:") ? undefined : "_blank"
|
||||
}
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClassName({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
className: "w-full justify-start",
|
||||
})}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { RotateCcw, TriangleAlert } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 错误边界(语言段内)。
|
||||
*
|
||||
* 必须是客户端组件。捕获渲染期间的运行时错误,
|
||||
* 提供重试入口,并把错误上报到控制台便于排查。
|
||||
*
|
||||
* 注意:`error.message` 在生产环境会被脱敏,因此面向用户只展示通用文案,
|
||||
* 详细信息(digest)留给开发者排查。
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[LocaleError]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex min-h-[60vh] w-full max-w-[640px] flex-col items-center justify-center px-lg text-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-14 place-items-center rounded-full bg-brand-error/10"
|
||||
>
|
||||
<TriangleAlert className="size-6 text-brand-error" />
|
||||
</span>
|
||||
|
||||
<h1 className="mt-lg text-heading-3 font-semibold text-ink">
|
||||
出了点问题
|
||||
</h1>
|
||||
|
||||
<p className="mt-sm max-w-[420px] text-body-md text-steel">
|
||||
页面加载时发生了意外错误,请稍后重试。如果问题持续出现,请通过「关于」页的联系方式告诉我。
|
||||
</p>
|
||||
|
||||
{error.digest ? (
|
||||
<p className="mt-sm font-mono text-caption text-muted">
|
||||
错误编号:{error.digest}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-xxl inline-flex h-10 items-center gap-xs rounded-full bg-primary px-lg text-button-md font-medium text-on-primary transition-colors duration-150 active:bg-charcoal"
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden="true" />
|
||||
重试
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Metadata } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import {
|
||||
getMessages,
|
||||
getTranslations,
|
||||
setRequestLocale,
|
||||
} from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { SiteHeader } from "@/components/layout/site-header";
|
||||
import { SiteFooter } from "@/components/layout/site-footer";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
/**
|
||||
* 语言段布局。
|
||||
*
|
||||
* 文档骨架(<html> / <body>)由根布局 app/layout.tsx 提供,
|
||||
* 这里只负责:校验 locale、注入 i18n Provider、渲染站点壳(页头 / 页脚)。
|
||||
*
|
||||
* 注意:因为根布局固定了 <html lang="zh-CN">,实际请求语言通过
|
||||
* `app/[locale]/layout.tsx` 无法直接改写 <html>;如需精确的 lang 属性,
|
||||
* 参见 README 的「已知限制」一节。
|
||||
*/
|
||||
|
||||
/** 预生成所有语言的路由 */
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
type Locale = (typeof routing.locales)[number];
|
||||
|
||||
type LocaleLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
/** 校验 locale 合法性,非法则交给 404 */
|
||||
function assertLocale(locale: string): asserts locale is Locale {
|
||||
if (!routing.locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
assertLocale(locale);
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "Site" });
|
||||
|
||||
const title = `${siteConfig.name} · ${t("tagline")}`;
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s · ${siteConfig.name}`,
|
||||
},
|
||||
description: siteConfig.description,
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: Object.fromEntries(
|
||||
routing.locales.map((item) => [item, `/${item}`]),
|
||||
),
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: siteConfig.name,
|
||||
locale,
|
||||
url: `${siteConfig.url}/${locale}`,
|
||||
title,
|
||||
description: siteConfig.description,
|
||||
images: [{ url: siteConfig.ogImage }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description: siteConfig.description,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: LocaleLayoutProps) {
|
||||
const { locale } = await params;
|
||||
assertLocale(locale);
|
||||
|
||||
// 启用静态渲染(next-intl 要求显式声明当前请求的 locale)
|
||||
setRequestLocale(locale);
|
||||
|
||||
const messages = await getMessages();
|
||||
const t = await getTranslations({ locale, namespace: "Nav" });
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-md focus:top-md focus:z-[60] focus:rounded-full focus:bg-primary focus:px-lg focus:py-xs focus:text-body-sm focus:text-on-primary"
|
||||
>
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<SiteHeader />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Compass } from "lucide-react";
|
||||
import { Container, Section } from "@/components/ui/layout";
|
||||
import { buttonClassName } from "@/components/ui/button-variants";
|
||||
import { getAllPosts } from "@/lib/posts";
|
||||
|
||||
/**
|
||||
* 404 页面(语言段内)。
|
||||
*
|
||||
* 位于 `app/[locale]/not-found.tsx`,因此能访问 next-intl 上下文并使用
|
||||
* 站点的页头 / 页脚布局。同时列出最新文章作为"继续浏览"的出口,
|
||||
* 避免用户走进死胡同。
|
||||
*/
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations("NotFound");
|
||||
const posts = (await getAllPosts()).slice(0, 3);
|
||||
|
||||
return (
|
||||
<Section spacing="lg">
|
||||
<Container size="narrow" className="flex flex-col items-center text-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-14 place-items-center rounded-full bg-surface"
|
||||
>
|
||||
<Compass className="size-6 text-steel" />
|
||||
</span>
|
||||
|
||||
<p className="mt-lg font-mono text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-brand-error">
|
||||
{t("eyebrow")}
|
||||
</p>
|
||||
|
||||
<h1 className="mt-sm text-heading-1 font-semibold text-ink text-balance-pretty">
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
<p className="mt-md max-w-[480px] text-subtitle text-steel text-balance-pretty">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
<div className="mt-xxl flex flex-col gap-sm sm:flex-row">
|
||||
<Link
|
||||
href="/"
|
||||
className={buttonClassName({ variant: "primary", size: "md" })}
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("backHome")}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/#posts"
|
||||
className={buttonClassName({ variant: "secondary", size: "md" })}
|
||||
>
|
||||
{t("browsePosts")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="mt-section w-full border-t border-hairline-soft pt-xl text-left">
|
||||
<h2 className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
|
||||
也许你想看看这些
|
||||
</h2>
|
||||
|
||||
<ul className="mt-md flex flex-col divide-y divide-hairline-soft">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex flex-col gap-xxs py-md transition-colors duration-150"
|
||||
>
|
||||
<span className="text-body-md font-medium text-ink group-hover:text-brand-green-deep">
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="text-body-sm text-steel">
|
||||
{post.summary}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Container, Section } from "@/components/ui/layout";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { buttonClassName } from "@/components/ui/button-variants";
|
||||
import { PostCard } from "@/components/post/post-card";
|
||||
import { MotionItem } from "@/components/motion/motion-item";
|
||||
import { HomeHero } from "@/components/home/hero";
|
||||
import { getAllPosts, getAllTags } from "@/lib/posts";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
/**
|
||||
* 首页渲染策略:ISR 缓存 60 秒。
|
||||
*
|
||||
* 内容来自数据库,因此不在构建期固化;访问时生成并缓存,
|
||||
* 更新文章后最多 60 秒自动生效。
|
||||
*
|
||||
* 注意:Next.js 会静态分析路由段配置,这里必须写成**字面量**,
|
||||
* 不能引用从其他模块导入的常量,否则会报
|
||||
* "Invalid segment configuration export detected"。
|
||||
*/
|
||||
export const revalidate = 60;
|
||||
|
||||
type HomePageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: HomePageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Site" });
|
||||
|
||||
return {
|
||||
title: `${siteConfig.name} · ${t("tagline")}`,
|
||||
description: siteConfig.description,
|
||||
alternates: { canonical: `/${locale}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations("Home");
|
||||
|
||||
// Hero 需要的统计数据量很小,直接在此等待;
|
||||
// 文章列表区块用 Suspense 包裹,先出骨架再填充内容。
|
||||
const [posts, tags] = await Promise.all([getAllPosts(), getAllTags()]);
|
||||
const earliestPost = posts.at(-1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeHero
|
||||
eyebrow={t("eyebrow")}
|
||||
title={t("heroTitle")}
|
||||
subtitle={t("heroSubtitle")}
|
||||
primaryLabel={t("ctaPrimary")}
|
||||
secondaryLabel={t("ctaSecondary")}
|
||||
stats={{
|
||||
posts: posts.length,
|
||||
tags: tags.length,
|
||||
since: earliestPost ? earliestPost.date.slice(0, 4) : "—",
|
||||
postsLabel: t("statsPosts"),
|
||||
tagsLabel: t("statsTags"),
|
||||
sinceLabel: t("statsSince"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Section id="posts" spacing="lg" aria-labelledby="latest-posts-heading">
|
||||
<Container>
|
||||
<SectionHeading
|
||||
eyebrow={t("latestEyebrow")}
|
||||
title={<span id="latest-posts-heading">{t("latestTitle")}</span>}
|
||||
description={t("latestDescription")}
|
||||
className="mb-xxl"
|
||||
/>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post, index) => (
|
||||
<MotionItem
|
||||
key={post.slug}
|
||||
delay={Math.min(index, 6) * 0.05}
|
||||
className="h-full"
|
||||
>
|
||||
<PostCard post={post} className="h-full" />
|
||||
</MotionItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-xxl flex justify-center">
|
||||
<Link
|
||||
href="/about"
|
||||
className={buttonClassName({ variant: "secondary" })}
|
||||
>
|
||||
{t("ctaSecondary")}
|
||||
<ArrowRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 无文章时的空状态(skill 要求前端具备加载态 / 空态 / 错误态) */
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-sm rounded-lg border border-dashed border-hairline bg-surface-soft px-xxl py-section text-center">
|
||||
<p className="text-heading-5 font-semibold text-ink">还没有已发布的文章</p>
|
||||
|
||||
<p className="max-w-[520px] text-body-sm text-steel">
|
||||
调用{" "}
|
||||
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
|
||||
POST /api/posts
|
||||
</code>{" "}
|
||||
创建文章(或运行{" "}
|
||||
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
|
||||
pnpm seed
|
||||
</code>{" "}
|
||||
导入示例内容),状态为{" "}
|
||||
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
|
||||
published
|
||||
</code>{" "}
|
||||
的文章就会出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkDatabaseHealth } from "@/lib/db";
|
||||
import { ErrorCode, ok, withErrorHandling } from "@/lib/api/response";
|
||||
|
||||
/**
|
||||
* GET /api/health —— 健康检查
|
||||
*
|
||||
* 用于部署后的可用性探测与监控。返回:
|
||||
* - status: "ok" | "degraded"
|
||||
* - database: 连通性与往返延迟
|
||||
* - timestamp: 服务端时间
|
||||
*
|
||||
* 数据库不可用时返回 503,便于负载均衡/监控直接判定为不健康。
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = withErrorHandling(async () => {
|
||||
const database = await checkDatabaseHealth();
|
||||
|
||||
const payload = {
|
||||
status: database.ok ? ("ok" as const) : ("degraded" as const),
|
||||
database: {
|
||||
ok: database.ok,
|
||||
latencyMs: database.latencyMs,
|
||||
...(database.error ? { error: database.error } : {}),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (!database.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ErrorCode.SERVICE_UNAVAILABLE,
|
||||
message: "数据库不可用",
|
||||
},
|
||||
...payload,
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return ok(payload);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
ok,
|
||||
noContent,
|
||||
withErrorHandling,
|
||||
parseJsonBody,
|
||||
ApiError,
|
||||
} from "@/lib/api/response";
|
||||
import { updatePostSchema } from "@/lib/validation/post";
|
||||
import {
|
||||
getPostBySlug,
|
||||
getPostBySlugIncludingDrafts,
|
||||
updatePost,
|
||||
deletePost,
|
||||
assertSlugAvailable,
|
||||
} from "@/lib/posts/repository";
|
||||
|
||||
/**
|
||||
* /api/posts/[slug] —— 单篇文章的读取、更新与删除
|
||||
*
|
||||
* 注意:GET 默认只返回已发布文章;传入 ?includeDrafts=true 可读到草稿,
|
||||
* 供后续接入后台管理时使用(v0.1 尚无鉴权,接入后台前应加上权限校验)。
|
||||
*/
|
||||
|
||||
type RouteContext = { params: Promise<{ slug: string }> };
|
||||
|
||||
/** GET /api/posts/[slug] —— 读取单篇文章(含正文) */
|
||||
export const GET = withErrorHandling(
|
||||
async (request: Request, context: RouteContext) => {
|
||||
const { slug } = await context.params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeDrafts = searchParams.get("includeDrafts") === "true";
|
||||
|
||||
const post = includeDrafts
|
||||
? await getPostBySlugIncludingDrafts(slug)
|
||||
: await getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
throw ApiError.notFound(`文章 "${slug}" 不存在或尚未发布`);
|
||||
}
|
||||
|
||||
return ok(post);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/posts/[slug] —— 更新文章(部分更新)
|
||||
*
|
||||
* 支持更新 slug 本身;此时会校验新 slug 未被占用。
|
||||
*/
|
||||
export const PATCH = withErrorHandling(
|
||||
async (request: Request, context: RouteContext) => {
|
||||
const { slug } = await context.params;
|
||||
const body = await parseJsonBody(request);
|
||||
const input = updatePostSchema.parse(body);
|
||||
|
||||
// 目标文章必须存在(含草稿,便于把草稿改为已发布)
|
||||
const existing = await getPostBySlugIncludingDrafts(slug);
|
||||
if (!existing) {
|
||||
throw ApiError.notFound(`文章 "${slug}" 不存在`);
|
||||
}
|
||||
|
||||
// 改 slug 时需要判重(排除自身)
|
||||
if (input.slug && input.slug !== slug) {
|
||||
await assertSlugAvailable(input.slug, slug);
|
||||
}
|
||||
|
||||
const updated = await updatePost(slug, input);
|
||||
if (!updated) {
|
||||
throw ApiError.notFound(`文章 "${slug}" 不存在`);
|
||||
}
|
||||
|
||||
return ok(updated);
|
||||
},
|
||||
);
|
||||
|
||||
/** PUT 与 PATCH 同义,便于不支持 PATCH 的客户端使用 */
|
||||
export const PUT = PATCH;
|
||||
|
||||
/** DELETE /api/posts/[slug] —— 删除文章 */
|
||||
export const DELETE = withErrorHandling(
|
||||
async (_request: Request, context: RouteContext) => {
|
||||
const { slug } = await context.params;
|
||||
|
||||
const removed = await deletePost(slug);
|
||||
if (!removed) {
|
||||
throw ApiError.notFound(`文章 "${slug}" 不存在`);
|
||||
}
|
||||
|
||||
// 204 No Content:删除成功且无响应体
|
||||
return noContent();
|
||||
},
|
||||
);
|
||||
|
||||
/** 列出该资源支持的方法 */
|
||||
export function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: { Allow: "GET, PATCH, PUT, DELETE, OPTIONS" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ok, created, withErrorHandling, parseJsonBody } from "@/lib/api/response";
|
||||
import { createPostSchema, listPostsQuerySchema } from "@/lib/validation/post";
|
||||
import { createPost, listPosts } from "@/lib/posts/repository";
|
||||
|
||||
/**
|
||||
* GET /api/posts —— 文章列表
|
||||
*
|
||||
* 查询参数:
|
||||
* - limit 1–100,默认 20
|
||||
* - offset 默认 0
|
||||
* - tag 按标签过滤
|
||||
* - status draft | published(不传则只返回已发布)
|
||||
* - includeDrafts true 时包含草稿(内部/管理用途)
|
||||
*
|
||||
* 响应:{ data: PostMeta[], meta: { total, limit, offset, hasMore } }
|
||||
*/
|
||||
export const GET = withErrorHandling(async (request: Request) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 校验查询参数:非法值直接返回 400 + 字段级错误
|
||||
const query = listPostsQuerySchema.parse(
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
);
|
||||
|
||||
const result = await listPosts({
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
tag: query.tag,
|
||||
includeDrafts: query.includeDrafts,
|
||||
status: query.status,
|
||||
});
|
||||
|
||||
return ok(result.items, {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
hasMore: result.hasMore,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/posts —— 新建文章
|
||||
*
|
||||
* 请求体(JSON):
|
||||
* - slug 必填,^[a-z0-9]+(-[a-z0-9]+)*$
|
||||
* - title 必填
|
||||
* - summary 必填
|
||||
* - content 必填(Markdown / MDX 源码)
|
||||
* - tags 可选,字符串数组
|
||||
* - status draft | published,默认 draft
|
||||
* - publishedAt 可选,ISO 日期;status=published 时缺省为当前时间
|
||||
* - cover 可选
|
||||
* - description 可选,覆盖 SEO 描述
|
||||
*
|
||||
* 响应:201 { data: Post }
|
||||
*/
|
||||
export const POST = withErrorHandling(async (request: Request) => {
|
||||
const body = await parseJsonBody(request);
|
||||
const input = createPostSchema.parse(body);
|
||||
|
||||
// slug 唯一性在应用层先查一次,给出友好的字段级报错;
|
||||
// 数据库唯一索引仍作为最终防线(并发下可能拦截,返回 409)
|
||||
const post = await createPost(input);
|
||||
|
||||
return created(post);
|
||||
});
|
||||
|
||||
/** 其他方法统一返回 405 */
|
||||
export function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
Allow: "GET, POST, OPTIONS",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ok, withErrorHandling } from "@/lib/api/response";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
/**
|
||||
* GET /api —— 接口索引
|
||||
*
|
||||
* 列出当前可用的接口,方便开发与调试时快速查看。
|
||||
* 生产环境可按需移除或限制访问。
|
||||
*/
|
||||
export const GET = withErrorHandling(async () => {
|
||||
return ok({
|
||||
name: siteConfig.name,
|
||||
version: "v0.1",
|
||||
endpoints: [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/health",
|
||||
description: "健康检查(含数据库连通性与延迟)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/posts",
|
||||
description: "文章列表,支持 limit / offset / tag / status / includeDrafts",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/posts",
|
||||
description: "新建文章",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/posts/[slug]",
|
||||
description: "文章详情(含正文)",
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/api/posts/[slug]",
|
||||
description: "更新文章(部分更新;PUT 同义)",
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/api/posts/[slug]",
|
||||
description: "删除文章",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/tags",
|
||||
description: "标签聚合(含各标签文章数)",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ok, withErrorHandling } from "@/lib/api/response";
|
||||
import { listTags } from "@/lib/posts/repository";
|
||||
|
||||
/**
|
||||
* GET /api/tags —— 标签聚合
|
||||
*
|
||||
* 返回全部已发布文章使用到的标签及各自文章数,按数量倒序。
|
||||
* 响应:{ data: [{ tag: string, count: number }], meta: { total } }
|
||||
*/
|
||||
export const GET = withErrorHandling(async () => {
|
||||
const tags = await listTags();
|
||||
|
||||
return ok(tags, { total: tags.length });
|
||||
});
|
||||
+445
-17
@@ -1,26 +1,454 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* ==========================================================================
|
||||
设计令牌(Design Tokens)
|
||||
来源:根目录 DESIGN.md(Mintlify 设计系统分析)
|
||||
命名规则:DESIGN.md 的 {colors.xxx} → --color-xxx(Tailwind v4 @theme)
|
||||
{rounded.xxx} → --radius-xxx
|
||||
{spacing.xxx} → --spacing-xxx
|
||||
========================================================================== */
|
||||
|
||||
@theme {
|
||||
/* ---------- 颜色:品牌与强调 ---------- */
|
||||
--color-primary: #0a0a0a;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-brand-green: #00d4a4;
|
||||
--color-brand-green-deep: #00b48a;
|
||||
--color-brand-green-soft: #7cebcb;
|
||||
--color-brand-tag: #3772cf;
|
||||
--color-brand-warn: #c37d0d;
|
||||
--color-brand-annotate: #1ba673;
|
||||
--color-brand-error: #d45656;
|
||||
--color-brand-cursor: #888888;
|
||||
|
||||
/* ---------- 颜色:Hero 氛围渐变 ---------- */
|
||||
--color-hero-sky-from: #87a8c8;
|
||||
--color-hero-sky-to: #f5e9d8;
|
||||
--color-hero-dark-from: #1a3d4a;
|
||||
--color-hero-dark-to: #2d5a4f;
|
||||
|
||||
/* ---------- 颜色:强调(证言卡片) ---------- */
|
||||
--color-testimonial-orange: #f55a3c;
|
||||
--color-testimonial-orange-deep: #cc3a1f;
|
||||
|
||||
/* ---------- 颜色:表面 ---------- */
|
||||
--color-canvas: #ffffff;
|
||||
--color-canvas-dark: #0a0a0a;
|
||||
--color-surface: #f7f7f7;
|
||||
--color-surface-soft: #fafafa;
|
||||
--color-surface-code: #1c1c1e;
|
||||
--color-hairline: #e5e5e5;
|
||||
--color-hairline-soft: #ededed;
|
||||
--color-hairline-dark: #1f1f1f;
|
||||
|
||||
/* ---------- 颜色:文字 ---------- */
|
||||
--color-ink: #0a0a0a;
|
||||
--color-charcoal: #1c1c1e;
|
||||
--color-slate: #3a3a3c;
|
||||
--color-steel: #5a5a5c;
|
||||
--color-stone: #888888;
|
||||
--color-muted: #a8a8aa;
|
||||
--color-on-dark: #ffffff;
|
||||
--color-on-dark-muted: #b3b3b3;
|
||||
|
||||
/* ---------- 圆角 ---------- */
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-xxl: 24px;
|
||||
|
||||
/* ---------- 间距(基础单位 4px) ---------- */
|
||||
--spacing-xxs: 4px;
|
||||
--spacing-xs: 8px;
|
||||
--spacing-sm: 12px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 20px;
|
||||
--spacing-xl: 24px;
|
||||
--spacing-xxl: 32px;
|
||||
--spacing-xxxl: 40px;
|
||||
--spacing-section-sm: 48px;
|
||||
--spacing-section: 64px;
|
||||
--spacing-section-lg: 96px;
|
||||
--spacing-hero: 120px;
|
||||
|
||||
/* ---------- 字体族 ---------- */
|
||||
/* 中文 Noto Sans SC、英文 Inter、代码 IBM Plex Mono
|
||||
顺序上 Inter 在前,中文字符由 Noto Sans SC 补齐(二者 unicode-range 互补)。 */
|
||||
--font-sans:
|
||||
var(--font-inter), var(--font-noto-sans-sc), -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono:
|
||||
var(--font-ibm-plex-mono), "SFMono-Regular", "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
|
||||
/* ---------- 字号阶梯(含行高 / 字距) ---------- */
|
||||
--text-hero-display: 72px;
|
||||
--text-hero-display--line-height: 1.05;
|
||||
--text-hero-display--letter-spacing: -2px;
|
||||
--text-display-lg: 56px;
|
||||
--text-display-lg--line-height: 1.1;
|
||||
--text-display-lg--letter-spacing: -1.5px;
|
||||
--text-heading-1: 48px;
|
||||
--text-heading-1--line-height: 1.1;
|
||||
--text-heading-1--letter-spacing: -1px;
|
||||
--text-heading-2: 36px;
|
||||
--text-heading-2--line-height: 1.2;
|
||||
--text-heading-2--letter-spacing: -0.5px;
|
||||
--text-heading-3: 28px;
|
||||
--text-heading-3--line-height: 1.25;
|
||||
--text-heading-4: 22px;
|
||||
--text-heading-4--line-height: 1.3;
|
||||
--text-heading-5: 18px;
|
||||
--text-heading-5--line-height: 1.4;
|
||||
--text-subtitle: 18px;
|
||||
--text-subtitle--line-height: 1.5;
|
||||
--text-body-md: 16px;
|
||||
--text-body-md--line-height: 1.5;
|
||||
--text-body-sm: 14px;
|
||||
--text-body-sm--line-height: 1.5;
|
||||
--text-caption: 13px;
|
||||
--text-caption--line-height: 1.4;
|
||||
--text-micro: 12px;
|
||||
--text-micro--line-height: 1.4;
|
||||
--text-micro-uppercase: 11px;
|
||||
--text-micro-uppercase--line-height: 1.4;
|
||||
--text-micro-uppercase--letter-spacing: 0.5px;
|
||||
--text-button-md: 14px;
|
||||
--text-button-md--line-height: 1.3;
|
||||
--text-code-md: 14px;
|
||||
--text-code-md--line-height: 1.5;
|
||||
--text-code-sm: 13px;
|
||||
--text-code-sm--line-height: 1.4;
|
||||
--text-code-inline: 13px;
|
||||
--text-code-inline--line-height: 1.3;
|
||||
|
||||
/* ---------- 阴影(DESIGN.md Elevation) ---------- */
|
||||
--shadow-level-1: rgba(0, 0, 0, 0.04) 0px 1px 2px 0px;
|
||||
--shadow-card: rgba(0, 0, 0, 0.08) 0px 4px 12px 0px;
|
||||
--shadow-mockup: rgba(0, 0, 0, 0.12) 0px 24px 48px -8px;
|
||||
--shadow-brand: rgba(0, 212, 164, 0.08) 0px 8px 24px;
|
||||
|
||||
/* ---------- 动效 ---------- */
|
||||
--ease-brand: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--animate-fade-up: fade-up 0.5s var(--ease-brand) both;
|
||||
--animate-shimmer: shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
/* ==========================================================================
|
||||
基础层
|
||||
========================================================================== */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* 站点默认承载于画布白,DESIGN.md 未发布暗色令牌,故不做自动暗色反转。 */
|
||||
--background: var(--color-canvas);
|
||||
--foreground: var(--color-ink);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-hairline);
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
/* 固定顶栏高度 + 一点余量,避免锚点跳转被遮挡 */
|
||||
scroll-padding-top: 88px;
|
||||
/* 防止长 URL / 代码片段把布局撑出横向滚动条 */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-md);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "liga" 0;
|
||||
}
|
||||
|
||||
/* 键盘可达性:统一焦点环 */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-green);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-brand-green-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
工具类
|
||||
========================================================================== */
|
||||
|
||||
@utility text-balance-pretty {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* 文章正文(由 MDX 渲染)——按 DESIGN.md 的文档式排版 */
|
||||
@layer components {
|
||||
.prose-blog {
|
||||
color: var(--color-charcoal);
|
||||
font-size: var(--text-body-md);
|
||||
line-height: 1.75;
|
||||
/* 长单词 / URL 不撑破容器 */
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.prose-blog > * + * {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
|
||||
.prose-blog h2 {
|
||||
margin-top: 2.5em;
|
||||
margin-bottom: 0.75em;
|
||||
font-size: var(--text-heading-3);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--color-ink);
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
|
||||
.prose-blog h3 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.6em;
|
||||
font-size: var(--text-heading-4);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-ink);
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
|
||||
.prose-blog h4 {
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: var(--text-heading-5);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--color-ink);
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
|
||||
.prose-blog p,
|
||||
.prose-blog li {
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.prose-blog a {
|
||||
color: var(--color-ink);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-brand-green);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.prose-blog a:hover {
|
||||
color: var(--color-brand-green-deep);
|
||||
}
|
||||
|
||||
.prose-blog strong {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-blog ul,
|
||||
.prose-blog ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.prose-blog ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.prose-blog ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.prose-blog li::marker {
|
||||
color: var(--color-stone);
|
||||
}
|
||||
|
||||
.prose-blog li + li {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.prose-blog blockquote {
|
||||
border-left: 3px solid var(--color-brand-green);
|
||||
padding: 0.25em 0 0.25em 1.25em;
|
||||
color: var(--color-steel);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.prose-blog blockquote p {
|
||||
color: var(--color-steel);
|
||||
}
|
||||
|
||||
.prose-blog hr {
|
||||
margin: 2.5em 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-hairline);
|
||||
}
|
||||
|
||||
.prose-blog img {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-hairline);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 行内代码(DESIGN.md: code-inline) */
|
||||
.prose-blog :not(pre) > code {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-charcoal);
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid var(--color-hairline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 代码块容器(DESIGN.md: code-block) */
|
||||
.prose-blog figure[data-rehype-pretty-code-figure] {
|
||||
margin: 1.75em 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-hairline-dark);
|
||||
background-color: var(--color-surface-code);
|
||||
}
|
||||
|
||||
.prose-blog figure[data-rehype-pretty-code-figure] figcaption {
|
||||
background-color: var(--color-surface-code);
|
||||
color: var(--color-on-dark-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-caption);
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--color-hairline-dark);
|
||||
}
|
||||
|
||||
.prose-blog figure[data-rehype-pretty-code-figure] pre {
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
overflow-x: auto;
|
||||
background-color: var(--color-surface-code);
|
||||
font-size: var(--text-code-md);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose-blog figure[data-rehype-pretty-code-figure] code {
|
||||
display: grid;
|
||||
font-size: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose-blog figure[data-rehype-pretty-code-figure] [data-line] {
|
||||
padding: 0 16px;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.prose-blog figure[data-rehype-pretty-code-figure] [data-highlighted-line] {
|
||||
background-color: rgba(0, 212, 164, 0.08);
|
||||
border-left-color: var(--color-brand-green);
|
||||
}
|
||||
|
||||
.prose-blog table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
border: 1px solid var(--color-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose-blog th {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-blog th,
|
||||
.prose-blog td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--color-hairline-soft);
|
||||
}
|
||||
|
||||
.prose-blog tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* 标题锚点(rehype-autolink-headings) */
|
||||
.prose-blog .heading-anchor {
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
margin-left: 0.4em;
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.prose-blog h2:hover .heading-anchor,
|
||||
.prose-blog h3:hover .heading-anchor,
|
||||
.prose-blog h4:hover .heading-anchor,
|
||||
.prose-blog .heading-anchor:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
+42
-22
@@ -1,29 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { fontVariables } from "@/lib/fonts";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
/**
|
||||
* 根布局。
|
||||
*
|
||||
* App Router 要求根布局渲染 <html> 与 <body>。由于所有页面都位于
|
||||
* `app/[locale]/` 下,这里只负责最外层的文档骨架与全局字体变量,
|
||||
* 具体语言、Provider 与导航壳由 `app/[locale]/layout.tsx` 承担。
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
title: {
|
||||
default: siteConfig.name,
|
||||
template: `%s · ${siteConfig.name}`,
|
||||
},
|
||||
description: siteConfig.description,
|
||||
applicationName: siteConfig.name,
|
||||
authors: [{ name: siteConfig.author.name }],
|
||||
creator: siteConfig.author.name,
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#ffffff",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" className={fontVariables} suppressHydrationWarning>
|
||||
<body className="min-h-dvh bg-canvas text-ink antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* 全局 404。
|
||||
*
|
||||
* 当请求路径无法匹配任何语言前缀(例如 /en/foo/bar 之外的情况)时,
|
||||
* Next.js 使用这个兜底页面。它位于根布局之下,没有 next-intl 上下文,
|
||||
* 因此文案是静态的,链接也直接指向默认语言。
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "页面不存在 · 404",
|
||||
description: "你访问的页面不存在,或者已经被移动到了别处。",
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-[720px] flex-col items-center justify-center px-lg text-center">
|
||||
<p className="font-mono text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-brand-error">
|
||||
Error 404
|
||||
</p>
|
||||
|
||||
<h1 className="mt-sm text-heading-2 font-semibold text-ink">
|
||||
页面走丢了
|
||||
</h1>
|
||||
|
||||
<p className="mt-md max-w-[480px] text-subtitle text-steel">
|
||||
你访问的页面不存在,或者已经被移动到了别处。
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-xxl inline-flex h-10 items-center rounded-full bg-primary px-lg text-button-md font-medium text-on-primary transition-colors duration-150 active:bg-charcoal"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
/**
|
||||
* 动态生成 robots.txt。
|
||||
*
|
||||
* 允许抓取全站(含 Next.js 静态资源),并指向 sitemap。
|
||||
* 部署到自定义域名后,只需修改 NEXT_PUBLIC_SITE_URL 环境变量即可。
|
||||
*/
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/api/"],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteConfig.url}/sitemap.xml`,
|
||||
host: siteConfig.url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { getAllPosts } from "@/lib/posts";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
/**
|
||||
* 动态生成 sitemap.xml。
|
||||
*
|
||||
* 覆盖:首页、关于页,以及全部已发布文章(草稿不会出现在这里)。
|
||||
* 每个条目都带上 hreflang alternate,帮助搜索引擎理解多语言对应关系。
|
||||
*
|
||||
* 内容来自数据库,因此 sitemap 也在请求时生成并缓存,
|
||||
* 而不是在构建期固化——否则构建之后新增的文章不会出现在 sitemap 中。
|
||||
*
|
||||
* 注意:Next.js 要求 sitemap 返回绝对 URL。
|
||||
*/
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const posts = await getAllPosts();
|
||||
|
||||
/** 为一条路径生成所有语言的 alternates */
|
||||
const buildAlternates = (path: string) => ({
|
||||
languages: Object.fromEntries(
|
||||
routing.locales.map((locale) => [
|
||||
locale,
|
||||
`${siteConfig.url}/${locale}${path}`,
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = routing.locales.flatMap(
|
||||
(locale) => [
|
||||
{
|
||||
url: `${siteConfig.url}/${locale}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: 1,
|
||||
alternates: buildAlternates(""),
|
||||
},
|
||||
{
|
||||
url: `${siteConfig.url}/${locale}/about`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
alternates: buildAlternates("/about"),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const postEntries: MetadataRoute.Sitemap = routing.locales.flatMap((locale) =>
|
||||
posts.map((post) => ({
|
||||
url: `${siteConfig.url}/${locale}/posts/${post.slug}`,
|
||||
lastModified: new Date(`${post.date}T00:00:00Z`),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.8,
|
||||
alternates: buildAlternates(`/posts/${post.slug}`),
|
||||
})),
|
||||
);
|
||||
|
||||
return [...staticEntries, ...postEntries];
|
||||
}
|
||||
Reference in new issue
Block a user