qianjh.me

“我的作品完成了。任凭朱庇特的怒气,任凭刀、火,任凭时光的蚕蚀,都不能毁灭我的作品。时光只能消毁我的肉身,死期愿意来就请它来吧,来终结我这飘摇的寿命。但是我的精萃部分却是不朽的,它将与日月同寿,我的声名也将永不磨灭。罗马的势力征服到哪里,那里我的作品就会被人们诵读。如果诗人的预言不爽,我的声名必将千载流传。” -- 奥维德

介绍

这是一篇使用 Nextjs 制作一个静态博客的记录,稍后会集成第三方工具实现评论功能,如果你感兴趣的话,非常欢迎探讨。

开发基础环境搭建

顺便说一下,使用 VSCode 进行编辑开发。

# 安装 Nextjs,reactpnpm install next@latest react@latest react-dom@latest # 在 package.json 文件中,添加开发脚本{  "scripts": {    "dev": "next dev",    "build": "next build",    "start": "next start",    "lint": "next lint"  }} # 项目目录结构和路由部分,阅读 Nextjs 文档/src/app

设置 TypeScript,ESLint 以及 Absolute Imports and Module Path Aliases,具体的方法请阅读 Nextjs 文档的安装部分

支持 MDX

  • 工具包解释与安装:
    • @mdx-js/react -用于在运行时将 MDX 内容渲染为 React 组件。
    • @next/mdx - 专为 Next.js 提供 MDX 集成工具。简化了在 Next.js 中使用 MDX 的配置过程。内部依赖 @mdx-js/loadevr 来处理 Webpack 的加载。
    • @mdx-js/loader - MDX 的 Webpack 加载器,用于在构建时将 .mdx 文件转换为可供 JavaScript 使用的模块。
    • @types/mdx - TypeScript 类型声明包,为 MDX 提供类型支持,主要作用是让开发者在使用 MDX 时获得更好的类型提示、错误检测和代码补全功能。(MDX 文件本质上会被编译为 React 组件)
pnpm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
  • @next/mdx 配置 - 项目根目录下 next.config.mjs。(在没有使用 @next/mdx 的项目中,需要手动在 webpack.config.js 中配置 @mdx-js/loader)
  • 完成这一步之后在 pages router 模式下,.md.mdx 文件将被正确解析。在 app router 模式下还需要进一步配置。
import createMDX from '@next/mdx';import remarkGfm from 'remark-gfm';import rehypeHighlight from 'rehype-highlight'; /** @type {import('next').NextConfig} */const nextConfig = {  // 扩展页面文件类型  pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],  // 其他 Next.js 配置  reactStrictMode: true,}; const withMDX = createMDX({  // 定义需要处理的文件扩展名  extension: /\.mdx?$/,  options: {    // 配置插件    // remarkPlugins: [remarkGfm],    // rehypePlugins: [rehypeHighlight],  },}); // 合并配置export default withMDX(nextConfig);
  1. app router 模式下,需要在根目录或者 src(如果使用 src 目录) 下创建 mdx-components.tsx 文件(注意文件名,名字由 nextjs 指定),它用来扩展或覆盖 MDX 组件映射。
  • 完成后就可以正确渲染,如 app/test/page.mdx 可以在浏览器中通过 根域名/test 访问(这样显然有些麻烦,我们希望使用一个文件夹统一管理 markdown 文件)
import type { MDXComponents } from 'mdx/types' export function useMDXComponents(components: MDXComponents): MDXComponents {  return {    ...components,  }}
  1. 前面的三个步骤是完全按照官方文档来的,现在对其进行扩展,比如希望在 /src/contents/ 文件中管理所有的 MDX 文件(由于 URL 中文需要额外处理,避免麻烦,contents 内的文件夹和文件禁止使用中文),并且使用动态路由的方式,在浏览器中通过跟域名/blog/* 来访问。(就像 pages router 模式下一样可以嵌套,同时也可以使用动态路由,如 /src/contents/test/test.mdx 可以通过 /blog/test/test 访问)
  • 使用 next-mdx-remote 来实现,它是一个用于 Next.js 的库,专门用于在服务器端(Server Side)加载和渲染 MDX 文件。支持将 Markdown/MDX 内容通过服务端渲染传递到前端页面,同时允许在运行时动态渲染组件。
  • 核心代码
# 安装 next-mdx-remotepnpm install next-mdx-remote # 创建动态路由 /src/app/blog/[...slug]/page.tsx,具体请看上面的提供的代码链接

使用 tailwindcss 和 sass

# 安装pnpm install -D tailwindcss postcss autoprefixerpnpm install --save-dev sass

安装并添加好配置文件后,在全局中引入,如在全局 layout.tsx 中引入 /src/styles/global.scss

/* tailwind.config.ts - 注意这里的 {js,ts,jsx,tsx,mdx,md} 不要添加空格 */import type { Config } from 'tailwindcss' export default {  content: [    './src/**/*.{js,ts,jsx,tsx,mdx,md}',    './src/styles/**/*.css',  ],  theme: {    extend: {},  },  plugins: [],} satisfies Config /* next.config.ts -- for saas *//** @type {import('next').NextConfig} */const nextConfig = {  pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],  reactStrictMode: true,  sassOptions: {  },}; /* postcss.config.js */module.exports = {  plugins: {    tailwindcss: {},    autoprefixer: {},  },} /* 在 /src/app/layout.tsx 中全局引入 */import "@/styles/global.scss";

全局亮色主题和暗色主题

由于后续步骤需要 css 样式化,在这里我们先使用 next-themes 设置全局的亮色主题和暗色主题,以方便后续操作。查看代码

# 安装pnpm install next-themes
# 创建一个主题切换组件# 为了配合 tailwindcss 的 `dark` 指令,将 `attribute` 设置为 `class`# 以及在 tailwind.config.ts 配置文件中设置 `darkMode: "class"`export function ThemesProviders({ children }) {   const [mounted, setMounted] = useState(false);   // 在组件挂载后才更新 mounted 状态,防止在服务端渲染时访问浏览器相关 API  useEffect(() => {    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;    document.documentElement.setAttribute(      'class',      isDark ? 'dark' : 'light'    );    setMounted(true)  }, []);   // 如果没有挂载(即还在服务端渲染),则不渲染任何内容  if (!mounted) {    return <>{children}</>  // 可以返回一个空的 fragment 或者加载中状态  }   return (    <ThemeProvider attribute="class" defaultTheme="system">      {children}    </ThemeProvider>  );} # 在全局 /src/app/layout.tsx 中使用<body>  <ThemesProviders>    {children}  </ThemesProviders></body> # 全局样式设定/* 默认情况下使用明亮模式 */:root {	--background-color: #fafafa;	--text-color: #000000;	--link-color: #1e90ff;} /* 暗黑模式的 CSS 变量 */[class='dark'] {	--background-color: #464545;	--text-color: #e0e0e0;	--link-color: #bb86fc;} /* 全局样式 */body {	background-color: var(--background-color);	color: var(--text-color);} a {  color: var(--link-color);}

remark 和 rehype

使用 remarkrehype 生态系统进行扩展,如 frontmatter,markdown 语法扩展(GFM),数学公式支持(Katex),代码语法高亮(rehype-pretty-code),清理并安全化 HTML(rehype-sanitize),代码压缩(rehype-minify)等等。

# 安装pnpm install remark-gfm rehype-sanitize remark-math rehype-katex katex rehype-pretty-code shiki # 使用 Katex 还须在全局引入 katex 数学公式样式,如在 /src/app/layout.tsx 中引入import "katex/dist/katex.min.css"; # 压缩代码# rehype-minify 是一系列的压缩工具集合,它提供了一个插件集合 rehype-preset-minify(使用有问题)# 使用其中一些就行:(最重要的 rehype-minify-whitespace)# rehype-pretty-code 生成的代码可以使用 `transformers` 选项进行代码压缩pnpm install rehype-minify-whitespace rehype-minify-css-style
# 最终核心代码展示 // 扩展 sanitize schema 以支持 KaTeXconst extendedSchema = {  ...defaultSchema,  tagNames: [    ...(defaultSchema.tagNames ?? []),    'section',    'sup',    // KaTeX 相关标签    'math',    'semantics',    'mrow',    'mi',    'mn',    'mo',    'msup',    'annotation',    'span',  // KaTeX 使用 span 渲染一些元素    'svg',    'path',    'figure',    'code'  ],  attributes: {    ...defaultSchema.attributes,    // KaTeX 相关属性    '*': ['className', 'style', 'class', 'data-line'],  // KaTeX 需要这些属性    span: ['class', 'style', 'data*'],    math: ['xmlns', 'display'],    svg: ['xmlns', 'viewBox', 'width', 'height', 'style', 'preserveAspectRatio'],    path: ['d', 'fill', 'stroke', 'stroke-width'],    figure: ['data*'],    pre: ['className', 'class', 'data*', 'dir'],    code: ['className', 'class', 'data-line-numbers', 'data-line-numbers-max-digits', 'data*'],  },}; const { content: compiledContent } = await compileMDX({  source: mdxContent,  options: {    mdxOptions: {    remarkPlugins: [remarkGfm, remarkMath],    rehypePlugins: [      [rehypePrettyCode, {        keepBackground: false,        defaultLang: {          block: "plaintext",        },        theme: {          dark: "github-dark",          light: "github-light"        },        transformers: [{          postprocess (html) {            return html.replace(/\n/g, '').trim();          },        }],      }],      rehypeKatex,      [rehypeSanitize, extendedSchema],      rehypeMinifyCssStyle,      rehypeMinifyWhitespace,     ]},  },}); return (  <main className='mdx-typography-default dark:mdx-typography-dark max-w-[768px] mx-auto'>    {compiledContent}  </main>);
# 安装pnpm install --save gray-matter // 使用方法,通常结合 Nextjs generateMetadata 来一起使用import matter from "gray-matter"; const mdxContent = fs.readFileSync(filePath, 'utf-8');const { data, content } = matter(mdxContent);

集成第三方评论系统 - Giscus

  1. 在 GitHub 仓库中启用 Discussions 功能。
  2. 访问 Giscus 官网,阅读它的配置选项。
  3. 使用 .env.local 文件,在 .gitignore 中添加此文件,以免较敏感信息直接显示在源代码之中。
 // 创建一个评论组件 src/lib/Giscus.tsx// 核心代码'use client';import React, { useEffect, useRef } from 'react'; export default function Giscus() {  const containerRef = useRef<HTMLDivElement>(null);   useEffect(() => {    if (!containerRef.current || containerRef.current.hasChildNodes()) return;     const script = document.createElement('script');    const config = {      src: 'https://giscus.app/client.js',      'data-repo': process.env.NEXT_PUBLIC_GISCUS_REPO!,      'data-repo-id': process.env.NEXT_PUBLIC_GISCUS_REPO_ID!,      'data-category': process.env.NEXT_PUBLIC_GISCUS_CATEGORY!,      'data-category-id': process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID!,      'data-mapping': 'pathname',      'data-strict': '0',      'data-reactions-enabled': '1',      'data-emit-metadata': '0',      'data-input-position': 'bottom',      'data-theme': resolvedTheme === 'dark' ? 'catppuccin_macchiato' : 'light',      'data-lang': 'en',      crossorigin: 'anonymous',      async: 'true'    };     Object.entries(config).forEach(([key, value]) => {      script.setAttribute(key, value);    });     containerRef.current.appendChild(script);     return () => {      script.remove();    };  }, []);   return <div ref={containerRef} className="mt-10" />;} // .env.localNEXT_PUBLIC_GISCUS_REPO=YOUR_USERNAME/YOUR_REPONEXT_PUBLIC_GISCUS_REPO_ID=YOUR_REPO_IDNEXT_PUBLIC_GISCUS_CATEGORY=AnnouncementsNEXT_PUBLIC_GISCUS_CATEGORY_ID=YOUR_CATEGORY_ID

Stay tuned...