目录结构
blog
├─ public
│ ├─ file.svg
│ ├─ globe.svg
│ ├─ logo.jpg
│ ├─ next.svg
│ ├─ vercel.svg
│ └─ window.svg
├─ src
│ ├─ app
│ │ ├─ [slug]
│ │ │ └─ page.tsx
│ │ ├─ about
│ │ │ └─ page.tsx
│ │ ├─ components
│ │ │ └─ header.tsx
│ │ ├─ favicon.ico
│ │ ├─ globals.css
│ │ ├─ layout.tsx
│ │ ├─ page.tsx
│ │ └─ posts
│ │ └─ page.tsx
│ ├─ components
│ │ ├─ FadeInUp.tsx
│ │ ├─ theme-provider.tsx
│ │ └─ theme-toggle.tsx
│ ├─ lib
│ │ ├─ posts.ts
│ │ └─ utils.ts
│ └─ posts
│ ├─ build-blog-with-nextjs.mdx
│ ├─ motion.mdx
│ ├─ nextjs.mdx
│ └─ react-core.mdx
├─ .prettierrc
├─ mdx-components.tsx
├─ next.config.mjs
├─ package.json
├─ postcss.config.mjs
├─ tailwind.config.ts
├─ tsconfig.json
└─ yarn.lock
mdx 文章存放在 @/posts
通过 fs 读取。
由于选择了 tailwindcss 会导致一些基本的dom样式丢失。
比如说 h1 h2 p li 等等。
这里采用的方案是使用官方工具 @tailwindcss/typography
yarn add @tailwindcss/typography -D
通过 @plugin 加载插件。
这里是tailwind4的新功能,实际作用和在 tailwindcss.config.ts 中配置是一样的。
@plugin "@tailwindcss/typography";
然后在 article 上加上内置的类名即可。
具体用法查看:@tailwindcss/typography
暗色模式直接通过 class 控制。
<article className="prose dark:prose-invert">
mdx 渲染
基本上就是按照官方文档来。
值得注意是,最好不要使用 turbopack 去构建,有些插件可能无法使用。
目前由于无法将 JavaScript 函数传递给 Rust,因此在 Turbopack 中还不能使用带有非序列化选项的 remark 和 rehype 插件
安装 mdx 相关包
yarn add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
# 安装 rehype、remark 插件
yarn add rehype-slug rehype-pretty-code remark-frontmatter remark-gfm
配置 mdx-components.tsx
根目录下创建 mdx-components.tsx 文件。
/* mdx-components.tsx */
import type { MDXComponents } from 'mdx/types';
export function useMDXComponents(components: MDXComponents): MDXComponents {
return { ...components };
}
配置 next.config.mjs
注意这里必须是 ESM 模块,mjs 才可以,否则会报错。
主要原因是因为 rehype-pretty-code 依赖的 shiki 包是 ESM 模块。
This package is ESM-only and currently supports shiki ^1.0.0.
To use the latest version in Next.js, ensure your config file is ESM: next.config.mjs. Here’s a full example: rehype-pretty-code/examples/next/next.config.mjs
/* next.config.mjs */
/**
* @typedef {import('next').NextConfig} NextConfig
* @typedef {Array<((config: NextConfig) => NextConfig)>} NextConfigPlugins
*/
import nextMDX from '@next/mdx';
import rehypeSlug from 'rehype-slug';
import rehypePrettyCode from 'rehype-pretty-code';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
/** @type {NextConfig} */
const nextConfig = {
cleanDistDir: true,
reactStrictMode: true,
poweredByHeader: false,
pageExtensions: ['md', 'mdx', 'tsx', 'ts', 'jsx', 'js'],
};
/** @type {import('rehype-pretty-code').Options} */
const options = {
/* 是否保留背景色 */
keepBackground: false,
defaultLang: 'plaintext',
/* 是否跳过内联代码 */
bypassInlineCode: true,
};
const withMDX = nextMDX({
extension: /\.(md|mdx)$/,
options: {
/* 解析 frontmatter & 解析 gfm */
remarkPlugins: [remarkFrontmatter, remarkGfm],
/* 代码高亮 & 锚点 */
rehypePlugins: [[rehypePrettyCode, options], [rehypeSlug]],
},
});
export default withMDX(nextConfig);
渲染 mdx 文件
直接通过 import 导入 mdx 文件,在 jsx 中渲染。
export interface Params {
slug: string;
}
interface Props {
params: Promise<Params>;
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const { default: Post } = await import(`@/posts/${slug}.mdx`);
return (
<article className="prose dark:prose-invert mx-auto max-w-4xl px-6 py-8">
<Post />
</article>
);
}
Dark Mode
tailwindcss
核心:
-
将 dark-mode 转为 class 模式。
-
通过 css 变量控制,深色浅色模式变化。
tailwind4 中新增通过 @指令 方式去设置 tailwindcss 配置。
实际效果和在config.ts 配置一样。
/* /src/app/globals.css */
/* tailwindcss-directives */
@import 'tailwindcss';
/* 加载插件 */
@plugin "@tailwindcss/typography";
/* dark-mode -> class: dark */
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
html.light {
/* Light theme variables */
--bg-main: #ffffff;
--bg-fore: #09090b;
--bg-card: #fafafa;
}
html.dark {
/* Dark theme variables */
--bg-main: #09090b;
--bg-fore: #ffffff;
--bg-card: #18181b;
}
}
/* @theme inline 指令 - 允许使用 var() 函数 */
/* 将 css 变量映射到 tailwindcss 中 */
/* colors 会自动生成到 [bg-*,text-*,shadow-*,...] */
/* 通过 className={'bg-main text-fore'} 使用 */
@theme inline {
--color-main: var(--bg-main);
--color-fore: var(--bg-fore);
--color-card: var(--bg-card);
}
nextjs
通过 next-themes 包实现。
yarn add next-themes
theme-provider
/* /src/app/components/theme-provider.tsx */
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
theme-toggle
/* /src/app/components/theme-toggle.tsx */
'use client';
import { useTheme } from 'next-themes';
import { Sun, Moon } from 'lucide-react';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
/* 切换主题 */
const handleThemeToggle = () => {
const switchTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
/* 使用 startViewTransition 实现动画 */
if (!document.startViewTransition) switchTheme();
else document.startViewTransition(switchTheme);
};
/* 确保组件挂载后再渲染,避免水合不匹配 */
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button className="flex h-9 w-9 items-center justify-center rounded-md">
<div className="h-4 w-4" />
</button>
);
}
return (
<button
onClick={handleThemeToggle}
className="bg-main hover:bg-fore/10 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full transition-all duration-300 hover:scale-120"
aria-label="切换主题"
>
{theme === 'dark' ? (
<Sun className="text-fore dark:text-fore h-4 w-4" />
) : (
<Moon className="text-fore dark:text-fore h-4 w-4" />
)}
</button>
);
}
layout
/* /src/app/layout.tsx */
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} bg-main min-h-screen w-screen antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Header />
<main className="m-auto w-3/4 flex-1 py-4">{children}</main>
</ThemeProvider>
</body>
</html>
);
}
view-transition
通过 view-transition 实现页面切换动画。
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="0" cy="0" r="18" fill="white" filter="url(%23blur)"/></svg>')
top left / 0 no-repeat;
mask-origin: content-box;
animation: scale 1s;
transform-origin: top left;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 1s;
transform-origin: top left;
z-index: -1;
}
@keyframes scale {
to {
mask-size: 350vmax;
}
}