# 前言
這篇是參考 鐵人賽 系列文章 「從零開始打造炫砲個人部落格」系列簡介 - Modern Next.js Blog 系列 實作的紀錄,實際操作上有遇到很多問題,但大致上跟著git修改的部分調整都可以修改成功。
這篇的解說很清楚,彙整的也很完整,推薦跟著教學文章操作。
使用的前端技術
| 功能 | Next.js 工具 |
| -------- | -------- |
| UI 樣式 | Tailwind CSS |
| 多語系 |next-i18next |
| SEO meta tags |next-seo |
| 指令面板 | kbar |
| 留言系統 | giscus |
| 換頁進度條 | nprogress |
| 更扎實的 JavaScript | TypeScript |
| 統一程式碼格式 | ESLint, Prettier |
| Markdown/MDX 文章處理 | Contentlayer |
| 網站託管 | Vercel |
Next.js:現代全端框架
Vercel:網站託管
Contentlayer:Markdown/MDX 文章處理
參考文章: https://ithelp.ithome.com.tw/articles/10291960
# 官方文件
https://nextjs.tw/learn/foundations/about-nextjs/what-is-nextjs
# 環境設定
建立專案
```
pnpm create next-app --typescript
```
啟動 Next.js 專案
```
pnpm dev
```
如果要在windows 上安裝pnpm
Step 1: First, you have to need to open the command prompt and run this command.
```
set-ExecutionPolicy RemoteSigned -Scope CurrentUser
```
Step 2: Now you have to run the second command on your system. This command is:
```
Get-ExecutionPolicy
```
Step 3: To view their policy, you need to run this command in your command prompt:
```
Get-ExecutionPolicy -list
```
Step 4: 安裝 pnpm
```
npm i -g pnpm
```
# 部屬在 vercel 上
部屬其實比較簡單 ,甚至不用創建 repo
1.開啟 vercel 介面 https://vercel.com/

2.選擇 Add NEW ... -> Project

> 這邊直接有 Template 可以 clone

> 這樣就創建好了
# Github CI/CD
當我們做完剛剛那套流程後,我們也完成 CI/CD 的設定了!未來當你 push code 到專案 repo 上時,Vercel 都會自動偵測到,並執行新一輪部署,將最新的 code 部署上去。

# 專案基礎設定
## 新增 .nvmrc 鎖定專案 Node.js 版本
新增 .nvmrc 檔案
```
v16.17.0
```
## 導入 ESLint、Prettier 統一程式碼風格
ESLint 可以幫助我們抓出 JavaScript code 的各種錯誤,而 Prettier 能幫我們統一程式碼風格。
他們各自都有設定檔能依照需求調整,這裡會導入 eslint-config-eason 這套我用了許久的 ESLint、Prettier 設定。
### 輸入指令安裝它,這會一次安裝很多套件:
```
npx install-peerdeps --pnpm -D eslint-config-eason
```
### 接著將 .eslintrc.json 改名成 .eslintrc.js,並修改內容如下:
```
module.exports = {
extends: [
'eason',
'next/core-web-vitals',
'plugin:prettier/recommended', // Make this the last element so prettier config overrides other formatting rules
],
rules: {
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
},
overrides: [
{
files: '**/*.{ts,tsx}',
extends: [
'eason/typescript',
'plugin:prettier/recommended', // Make this the last element so prettier config overrides other formatting rules
],
},
],
};
```
### 新增 .prettierrc.js:
```
module.exports = {
trailingComma: 'es5',
singleQuote: true,
printWidth: 80,
semi: true,
};
```
### 新增 .eslintignore 及 .prettierignore,內容從 .gitignore 複製過來:
```
# ============================= Copied from .gitignore =============================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# ============================ ./Copied from .gitignore ============================
```
### 修改 package.json,加入 lint 和 format 指令:
```
{
// ...
"scripts": {
// ...
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format:fix": "prettier --write './**/*.{css,scss,md,mdx,json}'"
},
// ...
}
```
這樣就完成了!你能用下面指令來處理程式碼的各種問題:
```
pnpm lint # 使用 ESLint 抓出問題
pnpm link:fix # 使用 ESLint 抓出問題,並修理能自動修復的問題
pnpm format:fix # 使用 Prettier 統一程式碼風格
```
### 在 VSCode 整合 ESLint 和 Prettier
如果你使用 VSCode 來寫程式,可以安裝 ESLint extension 和 Prettier extension,並設定檔案儲存時自動執行 ESLint 和 Prettier 修復錯誤,可以讓開發體驗更順暢。
### 存擋自動修正
除了手動快速修正外,你也可以透過添加 VS Code 的設定檔,如專案目錄下新增 .vscode/settings.json,
```
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
```
## 使用 Markdown 格式寫作
Contentlayer,在 Next.js 裡處理 Markdown 文章

>https://contentlayer.dev/
安裝 Contentlayer
輸入指令安裝相關套件:
```
pnpm add contentlayer next-contentlayer
pnpm add -D esbuild
```
將 next.config.js 改名為 next.config.mjs,並修改內容如下:
```
import { withContentlayer } from 'next-contentlayer';
/** @type {import('next').NextConfig} */
const nextConfig = withContentlayer({
reactStrictMode: true,
swcMinify: true,
eslint: {
// Warning: This allows production builds to successfully complete even if
// your project has ESLint errors.
ignoreDuringBuilds: true,
},
typescript: {
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
ignoreBuildErrors: true,
},
});
export default nextConfig;
```
修改 tsconfig.json:
```
{
"compilerOptions": {
// ...
"paths": {
// ...
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": [
// ...
".contentlayer/generated"
],
// ...
}
```
新增 contentlayer.config.ts,定義 Post 這個資源以及我們需要的欄位:
```
import { defineDocumentType, makeSource } from './src/lib/contentLayerAdapter';
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `content/posts/**/*.md`,
fields: {
title: {
type: 'string',
required: true,
},
description: {
type: 'string',
required: true,
},
slug: {
type: 'string',
required: true,
},
date: {
type: 'date',
required: true,
},
},
computedFields: {
path: {
type: 'string',
resolve: (post) => `/posts/${post.slug}`,
},
},
}));
export default makeSource({
contentDirPath: 'content',
documentTypes: [Post],
});
```
新增 src/lib/contentLayerAdapter.js:
```
import { allPosts, Post } from 'contentlayer/generated';
import {
defineDocumentType,
defineNestedType,
makeSource,
} from 'contentlayer/source-files';
export { allPosts, defineDocumentType, defineNestedType, makeSource, Post };
```
修改 ESLint 設定,忽略 Contentlayer 相關警告
Contentlayer 的 import 模式,會被 ESLint 視為錯誤,但不影響功能,需要特別處理。
修改 .eslintrc.js,忽略 contentLayerAdapter.js 的 import 警告:
```
module.exports = {
// ...
settings: {
// ...
'import/ignore': ['contentLayerAdapter.js'],
},
// ...
};
```
修改 .gitignore 和 .prettierignore,忽略 Contentlayer 會產生的檔案:
```
# ...
# Contentlayer
.contentlayer
```
修改 .eslintignore,忽略 Contentlayer 會產生的檔案,以及 contentLayerAdapter.js 的錯誤:
```
# ...
# Contentlayer
.contentlayer
# Ignore contentlayer eslint errors
contentLayerAdapter.js
```
這樣就完成 Contentlayer 設定了!
新增測試用 Markdown 文章
新增 content/posts/20220830-sample-post.md:
```
---
title: Sample post
description: My first post
slug: sample-post
date: 2022-08-30
type: Post
---
## This is my first sample post
Congratulations! You have created your first post.
```
驗證成果
執行 pnpm dev 啟動專案,看到 terminal 出現如下的訊息,就代表 Contentlayer 成功轉換完兩篇文章了:
Generated 2 documents in .contentlayer

## 排序文章,使用 date-fns
安裝 date-fns
```
pnpm add date-fns
```
修改 contentLayerAdapter.js
```
import { allPosts, Post } from 'contentlayer/generated';
import {
defineDocumentType,
defineNestedType,
makeSource,
} from 'contentlayer/source-files';
import { compareDesc } from 'date-fns'; # Add this
export { allPosts, defineDocumentType, defineNestedType, makeSource, Post };
# Add the following
export const allPostsNewToOld =
allPosts?.sort((a, b) => {
return compareDesc(new Date(a.date), new Date(b.date));
}) || [];
```
在首頁加入文章列表
修改 /src/pages/index.tsx:
```
import type { NextPage } from 'next';
import Head from 'next/head';
import Image from 'next/image';
import { allPostsNewToOld, Post } from '@/lib/contentLayerAdapter';
import styles from '@/styles/Home.module.css';
export function getStaticProps() {
const posts = allPostsNewToOld;
return { props: { posts } };
}
type Props = {
posts: Post[];
};
const Home: NextPage<Props> = ({ posts }) => {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.tsx</code>
</p>
<div className={styles.grid}>
{posts.map((post) => (
<a key={post.slug} href={post.path} className={styles.card}>
<h2>{post.title}</h2>
<p>{post.description}</p>
</a>
))}
</div>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span className={styles.logo}>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
</footer>
</div>
);
};
export default Home;
```
新增文章內頁,呈現文章內容
新增 src/pages/posts/[slug].tsx 檔案:
```
import { format, parseISO } from 'date-fns';
import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import Head from 'next/head';
import { allPosts, Post } from '@/lib/contentLayerAdapter';
import styles from '@/styles/Home.module.css';
export const getStaticPaths: GetStaticPaths = () => {
const paths = allPosts.map((post) => post.path);
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps<Props> = ({ params }) => {
const post = allPosts.find((post) => post.slug === params?.slug);
if (!post) {
return {
notFound: true,
};
}
return {
props: {
post,
},
};
};
type Props = {
post: Post;
};
const PostPage: NextPage<Props> = ({ post }) => {
return (
<div className={styles.container}>
<Head>
<title>{post.title}</title>
<meta name="description" content={post.description} />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>{post.title}</h1>
<time dateTime={post.date}>
{format(parseISO(post.date), 'LLLL d, yyyy')}
</time>
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</main>
</div>
);
};
export default PostPage;
```
## 安裝 Tailwind CSS 和相關 ESLint、Prettier 設定
安裝 Tailwind CSS
```
pnpm add -D tailwindcss autoprefixer postcss
npx tailwindcss init -p
```
安裝 Tailwind CSS 相關 ESLint, Prettier 規則
```
pnpm add -D eslint-plugin-tailwindcss prettier-plugin-tailwindcss
```
修改 .prettierrc.js,加入 prettier-plugin-tailwindcss 這個 plugin,最後結果如下:
```
module.exports = {
trailingComma: 'es5',
singleQuote: true,
printWidth: 80,
semi: true,
plugins: ['prettier-plugin-tailwindcss'],
};
```
## Dark Mode 深色模式支援,使用 Tailwind CSS、next-themes
安裝 next-themes
```
pnpm add next-themes
```
接著修改 /src/pages/_app.tsx,用 next-themes 提供的 <ThemeProvider/> 包住整個 App:
```
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import { ThemeProvider } from 'next-themes';
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class">
<Component {...pageProps} />
</ThemeProvider>
);
}
export default MyApp;
```
微調 /tailwind.config.js,設定 darkMode 使用 class 判定:
```
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
// 加入 darkMode
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
};
```
新增主題選擇器 component
新增 /src/components/ThemeSwitch.tsx,裡面主要使用了 next-themes 提供的 useTheme() 來取得當下主題、和切換主題:
```
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
);
};
export default ThemeSwitch;
```
在首頁放置 <ThemeSwitch/>,並設定些深色模式樣式
再來我們來把 <ThemeSwitch/> 放進首頁裡。
以及指定首頁各個 UI 在深色模式下的樣式,在 Tailwind CSS 裡面,都要使用 dark: 前綴來明確指定。
修改玩的 /src/pages/index.tsx 如下:
```
import type { NextPage } from 'next';
import Head from 'next/head';
import ThemeSwitch from '@/components/ThemeSwitch';
import { allPostsNewToOld, Post } from '@/lib/contentLayerAdapter';
export function getStaticProps() {
const posts = allPostsNewToOld;
return { props: { posts } };
}
type Props = {
posts: Post[];
};
const Home: NextPage<Props> = ({ posts }) => {
return (
<div>
<Head>
<title>My blog</title>
<meta name="description" content="Welcome to my blog" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="bg-white p-4 text-black dark:bg-black dark:text-white">
<h1 className="mb-6 text-4xl font-bold">Welcome to my blog!</h1>
<div className="my-4">
<ThemeSwitch />
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{posts.map((post) => (
<div
key={post.slug}
className="rounded-lg border border-black p-6 dark:border-white"
>
<a href={post.path}>
<h2 className="mb-4 text-2xl font-semibold">{post.title}</h2>
<p>{post.description}</p>
</a>
</div>
))}
</div>
</main>
</div>
);
};
export default Home;
```
## 安裝 clsx,方便根據不同條件組合 className
安裝 clsx
```
pnpm add clsx
```
安裝 @tailwindcss/typography
```
pnpm add -D @tailwindcss/typography
```
並修改 /tailwind.config.js,將它新增到 plugins 陣列中:
```
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
},
// 修改下面這行
plugins: [require('@tailwindcss/typography')],
};
```
@svgr/webpack,讓 Next.js 支援 svg 圖檔 import
切版過程我們會用到 svg 圖檔,但要能在 Next.js 的 JSX/TSX 裡直接 import svg 圖檔,方便對圖檔做客製化的話,需要做些設定才能實現。
安裝 @svgr/webpack
輸入指令來安裝 @svgr/webpack:
```
pnpm add -D @svgr/webpack
```
修改 /next.config.mjs
```
import { withContentlayer } from 'next-contentlayer';
/** @type {import('next').NextConfig} */
const nextConfig = withContentlayer({
reactStrictMode: true,
swcMinify: true,
// 加入以下 custom webpack 設定
// Support svg import
// ref: https://dev.to/dolearning/importing-svgs-to-next-js-nna
webpack: (config) => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
});
return config;
},
// 加入以上 custom webpack 設定
eslint: {
// Warning: This allows production builds to successfully complete even if
// your project has ESLint errors.
ignoreDuringBuilds: true,
},
typescript: {
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
ignoreBuildErrors: true,
},
});
export default nextConfig;
```
## 安裝 sass 套件,在 Next.js 支援 SCSS
```
pnpm add -D sass
```
## 文章內頁樣式切版
讓我們開始切版吧!會新增 5 個檔案、和修改 1 個檔案。
這邊樣式主要是基於 timlrx/tailwind-nextjs-starter-blog 專案修改而成的。
新增 /src/components/PageTitle.tsx:
```
type Props = {
children: React.ReactNode;
};
export default function PageTitle({ children }: Props) {
return (
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 transition-colors dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
{children}
</h1>
);
}
```
新增 /src/components/PostBody/PostBody.module.scss:
```
.postBody {
:global(.rehype-code-title) {
@apply -mb-3 rounded-tl rounded-tr bg-slate-600 px-4 pt-1 pb-2 font-mono text-sm text-gray-200;
}
div:global(.rehype-code-title) + pre {
@apply rounded-tl-none rounded-tr-none;
}
img {
@apply ml-auto mr-auto;
}
blockquote {
@apply not-italic;
p:first-of-type::before {
content: none;
}
p:last-of-type::after {
content: none;
}
}
}
```
新增 /src/components/PostBody/PostBody.tsx:
```
import clsx from 'clsx';
import styles from './PostBody.module.scss';
type Props = {
children: React.ReactNode;
};
export default function PostBody({ children }: Props) {
return (
<div
className={clsx(
'prose mx-auto transition-colors dark:prose-dark',
styles.postBody
)}
>
{children}
</div>
);
}
```
新增 /src/components/PostBody/index.ts:
```
import PostBody from './PostBody';
export default PostBody;
```
新增 /src/components/PostLayout.tsx:
```
import { useRouter } from 'next/router';
import CustomLink from '@/components/CustomLink';
import PageTitle from '@/components/PageTitle';
import PostBody from '@/components/PostBody';
import formatDate from '@/lib/formatDate';
export interface PostForPostLayout {
date: string;
title: string;
}
export type RelatedPostForPostLayout = {
title: string;
path: string;
} | null;
type Props = {
post: PostForPostLayout;
nextPost: RelatedPostForPostLayout;
prevPost: RelatedPostForPostLayout;
children: React.ReactNode;
};
export default function PostLayout({
post,
nextPost,
prevPost,
children,
}: Props) {
const { date, title } = post;
const { locale } = useRouter();
return (
<article>
<div className="divide-y divide-gray-200 transition-colors dark:divide-gray-700">
<header className="py-6">
<div className="space-y-1 text-center">
<div className="mb-4">
<PageTitle>{title}</PageTitle>
</div>
<dl className="space-y-10">
<div>
<dt className="sr-only">發佈時間</dt>
<dd className="text-base font-medium leading-6 text-gray-500 transition-colors dark:text-gray-400">
<time dateTime={date}>{formatDate(date, locale)}</time>
</dd>
</div>
</dl>
</div>
</header>
<div className="divide-y divide-gray-200 pt-10 pb-8 transition-colors dark:divide-gray-700">
<PostBody>{children}</PostBody>
</div>
<div
className="divide-y divide-gray-200 pb-8 transition-colors dark:divide-gray-700"
// style={{ gridTemplateRows: 'auto 1fr' }}
>
<footer>
<div className="flex flex-col gap-4 pt-4 text-base font-medium sm:flex-row sm:justify-between xl:gap-8 xl:pt-8">
{prevPost ? (
<div className="basis-6/12">
<h2 className="mb-1 text-xs uppercase tracking-wide text-gray-500 transition-colors dark:text-gray-400">
上一篇
</h2>
<CustomLink
href={prevPost.path}
className="text-primary-500 transition-colors hover:text-primary-600 dark:hover:text-primary-400"
>
← {prevPost.title}
</CustomLink>
</div>
) : (
<div />
)}
{nextPost && (
<div className="basis-6/12">
<h2 className="mb-1 text-left text-xs uppercase tracking-wide text-gray-500 transition-colors dark:text-gray-400 sm:text-right">
下一篇
</h2>
<CustomLink
href={nextPost.path}
className="block text-primary-500 transition-colors hover:text-primary-600 dark:hover:text-primary-400 sm:text-right"
>
{nextPost.title} →
</CustomLink>
</div>
)}
</div>
</footer>
</div>
</div>
</article>
);
}
```
修改 /src/pages/posts/[slug].tsx:
```
import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import Head from 'next/head';
import { useMDXComponent } from 'next-contentlayer/hooks';
import PostLayout, {
PostForPostLayout,
RelatedPostForPostLayout,
} from '@/components/PostLayout';
import { allPosts, allPostsNewToOld } from '@/lib/contentLayerAdapter';
type PostForPostPage = PostForPostLayout & {
title: string;
description: string;
body: {
code: string;
};
};
type Props = {
post: PostForPostPage;
prevPost: RelatedPostForPostLayout;
nextPost: RelatedPostForPostLayout;
};
export const getStaticPaths: GetStaticPaths = () => {
const paths = allPosts.map((post) => post.path);
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps<Props> = ({ params }) => {
const postIndex = allPostsNewToOld.findIndex(
(post) => post.slug === params?.slug
);
if (postIndex === -1) {
return {
notFound: true,
};
}
const prevFull = allPostsNewToOld[postIndex + 1] || null;
const prevPost: RelatedPostForPostLayout = prevFull
? { title: prevFull.title, path: prevFull.path }
: null;
const nextFull = allPostsNewToOld[postIndex - 1] || null;
const nextPost: RelatedPostForPostLayout = nextFull
? { title: nextFull.title, path: nextFull.path }
: null;
const postFull = allPostsNewToOld[postIndex];
const post: PostForPostPage = {
title: postFull.title,
date: postFull.date,
description: postFull.description,
body: {
code: postFull.body.code,
},
};
if (!post) {
return {
notFound: true,
};
}
return {
props: {
post,
prevPost,
nextPost,
},
};
};
const PostPage: NextPage<Props> = ({ post, prevPost, nextPost }) => {
const {
description,
title,
body: { code },
} = post;
const MDXContent = useMDXComponent(code);
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="icon" href="/favicon.ico" />
</Head>
<PostLayout post={post} prevPost={prevPost} nextPost={nextPost}>
<MDXContent />
</PostLayout>
</>
);
};
export default PostPage;
```
使用 pnpm dev 並進入任何文章內頁,就會看到樣式變漂亮了!

>https://reimagined-invention-77wxpr5wr6qhprx7-3000.app.github.dev/posts/markdown-demo
## 使用 rehype-prism-plus 加入 Syntax Highlighting
安裝 rehype-prism-plus
```
pnpm add rehype-prism-plus
```
修改 contentlayer.config.ts,在 mdx 的 rehypePlugins 加入 rehypePrism:
```
// 加入下面這行
import rehypePrism from 'rehype-prism-plus';
// ...
export default makeSource({
// ...
// 加入下面這行
mdx: { rehypePlugins: [[rehypePrism, { ignoreMissing: true }]] },
});
```
新增 src/styles/prism-plus.css,rehype-prism-plus 針對行數 highlight 的樣式:
```
/* https://github.com/timlrx/rehype-prism-plus#styling */
pre {
overflow-x: auto;
}
/**
* Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/
* 1. Make the element just wide enough to fit its content.
* 2. Always fill the visible space in .code-highlight.
*/
.code-highlight {
float: left; /* 1 */
min-width: 100%; /* 2 */
}
.code-line {
display: block;
padding-left: 16px;
padding-right: 16px;
margin-left: -16px;
margin-right: -16px;
border-left-width: 4px;
border-left-color: rgba(31, 41, 55, 0); /* Set code block color */
}
.code-line.inserted {
background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */
}
.code-line.deleted {
background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */
}
.highlight-line {
margin-left: -16px;
margin-right: -16px;
background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */
border-left-width: 4px;
border-left-color: rgb(59, 130, 246); /* Set highlight accent border color */
}
.line-number::before {
display: inline-block;
width: 1rem;
text-align: right;
margin-right: 16px;
margin-left: -8px;
color: rgb(156, 163, 175); /* Line number color */
content: attr(line);
}
```
新增 src/styles/prism-dracula.css,PrismJS 的自選主題樣式:
```
/**
* Dracula Theme originally by Zeno Rocha [@zenorocha]
* https://draculatheme.com/
*
* Ported for PrismJS by Albert Vallverdu [@byverdu]
*/
code[class*='language-'],
pre[class*='language-'] {
color: #f8f8f2;
background: none;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*='language-'],
pre[class*='language-'] {
background: #282a36;
}
/* Inline code */
:not(pre) > code[class*='language-'] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #6272a4;
}
.token.punctuation {
color: #f8f8f2;
}
.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #ff79c6;
}
.token.boolean,
.token.number {
color: #bd93f9;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #50fa7b;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string,
.token.variable {
color: #f8f8f2;
}
.token.atrule,
.token.attr-value,
.token.function,
.token.class-name {
color: #f1fa8c;
}
.token.keyword {
color: #8be9fd;
}
.token.regex,
.token.important {
color: #ffb86c;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
```
修改 src/pages/_app.tsx,引入新的兩個 css 檔:
```
import '@/styles/prism-dracula.css';
import '@/styles/prism-plus.css';
// ...
```
新增 /content/posts/20220901-post-with-code.mdx:
```
---
title: Post with code
description: My post with code
slug: post-with-code
date: 2022-09-01
type: Post
---
## Some post with code!
Some other posts! with `some inline code`!
```js showLineNumbers
const a = 1;
a = 2;
```
```tsx showLineNumbers {5,15-17}
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import Head from "next/head";
import { ThemeProvider } from "next-themes";
import LayoutWrapper from "@/components/LayoutWrapper";
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class">
<Head>
<meta name="viewport" content="viewport-fit=cover" />
</Head>
<LayoutWrapper>
<Component {...pageProps} />
</LayoutWrapper>
</ThemeProvider>
);
}
export default MyApp;
```
```diff-js showLineNumbers {3-4}
const a = 1;
- a = 2;
+ a = 2;
a = 3;
```
```shell
$ echo "Hello world!"
```
```python
print("Hello world!")
```
```java
System.out.println("Hello world!");
```
```csharp
Console.WriteLine("Hello world!");
```
```c
printf("Hello world!");
```
```cpp
std::cout << "Hello world!";
```
```go
fmt.Println("Hello world!")
```
```
## 使用 rehype-code-titles,為每個程式碼區塊加入標題
安裝 rehype-code-titles:
```
pnpm add rehype-code-titles
```
啟用它,修改 /contentlayer.config.ts,將 rehype-code-titles 加入到 rehypePlugins 列表:
```
import rehypeCodeTitles from 'rehype-code-titles'; // 新增這行
import rehypePrism from 'rehype-prism-plus';
import { defineDocumentType, makeSource } from './src/lib/contentLayerAdapter';
// ...
export default makeSource({
contentDirPath: 'content',
documentTypes: [Post],
mdx: {
// 新增到 rehypePlugins 列表裡
rehypePlugins: [rehypeCodeTitles, [rehypePrism, { ignoreMissing: true }]],
},
});
```
到此為止就完成 rehype-code-titles 的安裝了,但他預設不會有任何樣式,我們需要自己指定。
修改 /src/components/PostBody/PostBody.module.scss:
```
.postBody {
# 新增下面這兩塊
:global(.rehype-code-title) {
@apply -mb-3 rounded-tl rounded-tr bg-slate-600 px-4 pt-1 pb-2 font-mono text-sm text-gray-200;
}
div:global(.rehype-code-title) + pre {
@apply rounded-tl-none rounded-tr-none;
}
# ...
}
```
修改文章,在程式碼區塊加入標題
在程式碼區塊 Markdown 的程式語言標示後,加入 : 冒號,並輸入任意文字,這些文字就會被 rehype-code-title 當成標題了。
像是這樣,修改 /content/posts/20220901-post-with-code.mdx:
https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/commit/4f042ea8861adaa2d53fd7fc1a461459862719de
## 加入程式碼區塊「複製按鈕」
新增 <CustomPre/>
新增 /src/components/CustomPre.tsx:
```
// ref: https://philstainer.io/blog/copy-code-button-markdown
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import { copyToClipboard } from '@/lib/copyToClipboard';
import { removeDuplicateNewLine } from '@/lib/removeDuplicateNewLine';
type Props = React.ComponentPropsWithoutRef<'pre'>;
function CustomPre({ children, className, ...props }: Props) {
const preRef = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);
const onClick = async () => {
if (preRef.current?.innerText) {
await copyToClipboard(removeDuplicateNewLine(preRef.current.innerText));
setCopied(true);
}
};
return (
<div className="group relative">
<pre
{...props}
ref={preRef}
className={clsx(className, 'focus:outline-none')}
>
<div className="absolute top-0 right-0 m-2 flex items-center rounded-md bg-[#282a36] dark:bg-[#262626]">
<span
className={clsx('hidden px-2 text-xs text-green-400 ease-in', {
'group-hover:flex': copied,
})}
>
已複製!
</span>
<button
type="button"
aria-label="Copy to Clipboard"
onClick={onClick}
disabled={copied}
className={clsx(
'hidden rounded-md border bg-transparent p-2 transition ease-in focus:outline-none group-hover:flex',
{
'border-green-400': copied,
'border-gray-600 hover:border-gray-400 focus:ring-4 focus:ring-gray-200/50 dark:border-gray-700 dark:hover:border-gray-400':
!copied,
}
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={clsx('pointer-events-none h-4 w-4', {
'text-gray-400 dark:text-gray-400': !copied,
'text-green-400': copied,
})}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
className={clsx({ block: !copied, hidden: copied })}
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
className={clsx({ block: copied, hidden: !copied })}
/>
</svg>
</button>
</div>
{children}
</pre>
</div>
);
}
export default CustomPre;
```
新增處理複製邏輯的 2 個 Function
新增 /src/lib/copyToClipboard.ts:
```
// ref: https://philstainer.io/blog/copy-code-button-markdown
export const copyToClipboard = (text: string) => {
return new Promise((resolve, reject) => {
if (navigator?.clipboard) {
const cb = navigator.clipboard;
cb.writeText(text).then(resolve).catch(reject);
} else {
try {
const body = document.querySelector('body');
const textarea = document.createElement('textarea');
body?.appendChild(textarea);
textarea.value = text;
textarea.select();
document.execCommand('copy');
body?.removeChild(textarea);
resolve(void 0);
} catch (e) {
reject(e);
}
}
});
};
```
新增 /src/lib/removeDuplicateNewLine.ts:
```
// Workaround to work with rehype-prism-plus generated Pre blog for copy to clipboard feature
export const removeDuplicateNewLine = (text: string): string => {
if (!text) return text;
return text
.replace(/(\r\n\r\n)/gm, `\r\n`)
.replace(/(\n\n)/gm, `\n`)
.replace(/(\r\r)/gm, `\r`);
};
```
用 <CustomPre/> 替換掉文章內文程式碼區塊
新增 /src/lib/mdxComponents.ts:
```
import CustomPre from '@/components/CustomPre';
// Custom components/renderers to pass to MDX.
const mdxComponents = {
pre: CustomPre,
};
export default mdxComponents;
```
修改 /src/pages/posts/[slug].tsx,import mdxComponents 傳給 <MDXContent>:
```
import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
// ...
// 新增下面這行,import mdxComponents
import mdxComponents from '@/lib/mdxComponents';
// ...
const PostPage: NextPage<Props> = ({ post, prevPost, nextPost }) => {
// ...
const MDXContent = useMDXComponent(code);
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="icon" href="/favicon.ico" />
</Head>
<PostLayout post={post} prevPost={prevPost} nextPost={nextPost}>
// 修改下面這行,把 mdxComponents 傳給 MDXContent
<MDXContent components={mdxComponents} />
</PostLayout>
</>
);
};
export default PostPage;
```
### SEO 優化: 加入 Open Graph、LD-JSON 等 SEO meta data
安裝 next-seo
```
pnpm add next-seo
```
新增文章欄位需要修改 /contentlayer.config.ts:
```
// ...
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `content/posts/**/*.mdx`,
contentType: 'mdx',
fields: {
// ...
date: {
type: 'date',
required: true,
},
// 新增 socialImage
socialImage: {
type: 'string',
},
},
// ...
}));
// ...
```
為文章指定 socialImage
接著就能在文章最前面區塊指定 socialImage 了,你可以挑一篇現成文章來加,或是像我這個 commit 一樣,新增一張圖片在 /public 裡面,並新增一篇文章來使用它當縮圖:
https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/commit/799d5ec203ac227fe7a8500c2f9ddcb3677a0e1a
使用 next-seo 設定各頁面 meta data
完整改動可以看這支 commit:
https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/commit/9c105bc2ba84286eac0d26580240a93e104ddee6
新增 /src/configs/siteConfigs.ts,並修改成你的網站想要的內容:
```
const fqdn = 'https://nextjs-tailwind-contentlayer-blog-starter.vercel.app';
const logoPath = '/logo.png';
const bannerPath = '/og-image.png';
export const siteConfigs = {
title: 'Next.js Tailwind Contentlayer Blog Starter',
titleShort: 'Next Blog',
description:
'Blog starter template with modern frontend technologies like Next.js, Tailwind CSS, Contentlayer, i18Next',
author: 'Tony Stark',
fqdn: fqdn,
logoPath: logoPath,
logoUrl: fqdn + logoPath,
bannerPath: bannerPath,
bannerUrl: fqdn + bannerPath,
twitterID: '@EasonChang_me',
datePublished: '2022-09-01',
};
```
新增 /src/lib/getPostOGImage.ts:
```
import { siteConfigs } from '@/configs/siteConfigs';
export const getPostOGImage = (socialImage: string | null): string => {
if (!socialImage) {
return siteConfigs.bannerUrl;
}
if (socialImage.startsWith('http')) {
return socialImage;
}
return siteConfigs.fqdn + socialImage;
};
```
修改 /src/pages/_app.tsx:
```
import '@/styles/globals.css';
import '@/styles/prism-dracula.css';
import '@/styles/prism-plus.css';
import type { AppProps } from 'next/app';
import { DefaultSeo } from 'next-seo';
import { ThemeProvider } from 'next-themes';
import LayoutWrapper from '@/components/LayoutWrapper';
import { siteConfigs } from '@/configs/siteConfigs';
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class">
<DefaultSeo
titleTemplate={`%s | ${siteConfigs.titleShort}`}
defaultTitle={siteConfigs.title}
description={siteConfigs.description}
canonical={siteConfigs.fqdn}
openGraph={{
title: siteConfigs.title,
description: siteConfigs.description,
url: siteConfigs.fqdn,
images: [
{
url: siteConfigs.bannerUrl,
},
],
site_name: siteConfigs.title,
type: 'website',
}}
twitter={{
handle: siteConfigs.twitterID,
site: siteConfigs.twitterID,
cardType: 'summary_large_image',
}}
additionalMetaTags={[
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
]}
additionalLinkTags={[
{
rel: 'icon',
href: siteConfigs.logoPath,
},
]}
/>
<LayoutWrapper>
<Component {...pageProps} />
</LayoutWrapper>
</ThemeProvider>
);
}
export default MyApp;
```
修改 /src/pages/index.tsx:
```
import type { NextPage } from 'next';
import { GetStaticProps } from 'next';
import { ArticleJsonLd } from 'next-seo';
import PostList, { PostForPostList } from '@/components/PostList';
import { siteConfigs } from '@/configs/siteConfigs';
import { allPostsNewToOld } from '@/lib/contentLayerAdapter';
type PostForIndexPage = PostForPostList;
type Props = {
posts: PostForIndexPage[];
};
export const getStaticProps: GetStaticProps<Props> = () => {
const posts = allPostsNewToOld.map((post) => ({
slug: post.slug,
date: post.date,
title: post.title,
description: post.description,
path: post.path,
})) as PostForIndexPage[];
return { props: { posts } };
};
const Home: NextPage<Props> = ({ posts }) => {
return (
<>
<ArticleJsonLd
type="Blog"
url={siteConfigs.fqdn}
title={siteConfigs.title}
images={[siteConfigs.bannerUrl]}
datePublished={siteConfigs.datePublished}
authorName={siteConfigs.author}
description={siteConfigs.description}
/>
<div className="prose my-12 space-y-2 transition-colors dark:prose-dark md:prose-lg md:space-y-5">
<h1 className="text-center sm:text-left">Hey,I am Iron Man ?</h1>
<p>我是 Tony Stark,不是 Stank!</p>
<p>老子很有錢,拯救過很多次世界。</p>
<p>我討厭外星人、紫色的東西、和紫色外星人。</p>
</div>
<div className="my-4 divide-y divide-gray-200 transition-colors dark:divide-gray-700">
<div className="prose prose-lg my-8 dark:prose-dark">
<h2>最新文章</h2>
</div>
<PostList posts={posts} />
</div>
</>
);
};
export default Home;
```
修改 /src/pages/posts/[slug].tsx:
```
import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import { useMDXComponent } from 'next-contentlayer/hooks';
import { ArticleJsonLd, NextSeo } from 'next-seo';
import PostLayout, {
PostForPostLayout,
RelatedPostForPostLayout,
} from '@/components/PostLayout';
import { siteConfigs } from '@/configs/siteConfigs';
import { allPosts, allPostsNewToOld } from '@/lib/contentLayerAdapter';
import { getPostOGImage } from '@/lib/getPostOGImage';
import mdxComponents from '@/lib/mdxComponents';
type PostForPostPage = PostForPostLayout & {
title: string;
description: string;
date: string;
path: string;
socialImage: string | null;
body: {
code: string;
};
};
type Props = {
post: PostForPostPage;
prevPost: RelatedPostForPostLayout;
nextPost: RelatedPostForPostLayout;
};
export const getStaticPaths: GetStaticPaths = () => {
const paths = allPosts.map((post) => post.path);
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps<Props> = ({ params }) => {
const postIndex = allPostsNewToOld.findIndex(
(post) => post.slug === params?.slug
);
if (postIndex === -1) {
return {
notFound: true,
};
}
const prevFull = allPostsNewToOld[postIndex + 1] || null;
const prevPost: RelatedPostForPostLayout = prevFull
? { title: prevFull.title, path: prevFull.path }
: null;
const nextFull = allPostsNewToOld[postIndex - 1] || null;
const nextPost: RelatedPostForPostLayout = nextFull
? { title: nextFull.title, path: nextFull.path }
: null;
const postFull = allPostsNewToOld[postIndex];
const post: PostForPostPage = {
title: postFull.title,
date: postFull.date,
description: postFull.description,
path: postFull.path,
socialImage: postFull.socialImage || null,
body: {
code: postFull.body.code,
},
};
if (!post) {
return {
notFound: true,
};
}
return {
props: {
post,
prevPost,
nextPost,
},
};
};
const PostPage: NextPage<Props> = ({ post, prevPost, nextPost }) => {
const {
description,
title,
date,
path,
socialImage,
body: { code },
} = post;
const url = siteConfigs.fqdn + path;
const ogImage = getPostOGImage(socialImage);
const MDXContent = useMDXComponent(code);
return (
<>
<NextSeo
title={title}
description={description}
canonical={url}
openGraph={{
title: title,
description: description,
url: url,
images: [
{
url: ogImage,
},
],
type: 'article',
article: {
publishedTime: date,
modifiedTime: date,
},
}}
/>
<ArticleJsonLd
url={url}
title={title}
images={[ogImage]}
datePublished={date}
dateModified={date}
authorName={siteConfigs.author}
description={description}
/>
<PostLayout post={post} prevPost={prevPost} nextPost={nextPost}>
<MDXContent components={mdxComponents} />
</PostLayout>
</>
);
};
export default PostPage;
```
新增網站 Logo 圖片,放在 /public/logo.png。
新增網站預設 socialImage,放在 /public/og-image.png。
可以安裝這套 Chrome 瀏覽器 extension 來更方便查看每個頁面的 meta data:META SEO inspector - Chrome 線上應用程式商店
http://localhost:3000/
http://localhost:3000/posts/post-with-code
### 使用 next-sitemap 生成 Sitemap
安裝 next-sitemap
```
pnpm add -D next-sitemap
```
新增 next-sitemap.config.js:
```
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: 'https://nextjs-tailwind-contentlayer-blog-starter.vercel.app',
generateRobotsTxt: true,
};
```
修改 package.json 的 build script:
```
{
// ...
"scripts": {
// ...
"build": "next build && next-sitemap --config next-sitemap.config.js",
// ...
},
// ...
}
```
修改 .gitignore、.eslintignore、.prettierignore,忽略生成的 sitemap:
```
# ...
# 加入下面這兩條規則
# Sitemap related files (generated by next-sitemap)
/public/robots.txt
/public/sitemap*.xml
```
產生 sitemap.xml
```
pnpm build
```
產生檔案:
```
-public
├ robots.txt
├ sitemap.xml
└ sitemap-0.xml
```

>執行 pnpm build 的最後,next-sitemap 也會跟你說它幫你生成了 Sitemap 相關檔案
### 使用 feed 生成 RSS Feed
安裝 feed 套件
```
pnpm add feed
```
修改 src/configs/siteConfigs.ts,新增 credit 和 email:
```
// ...
export const siteConfigs = {
// ...
credit: 'Stark Industries',
email: 'stark@example.com',
};
```
新增 src/lib/generateRSS.ts:
```
import { Feed } from 'feed';
import { writeFileSync } from 'fs';
import { siteConfigs } from '@/configs/siteConfigs';
import { allPostsNewToOld } from '@/lib/contentLayerAdapter';
import { getPostOGImage } from '@/lib/getPostOGImage';
export default function generateRSS() {
const author = {
name: siteConfigs.author,
email: siteConfigs.email,
link: siteConfigs.fqdn,
};
const feed = new Feed({
title: siteConfigs.title,
description: siteConfigs.description,
id: siteConfigs.fqdn,
link: siteConfigs.fqdn,
image: siteConfigs.logoUrl,
favicon: siteConfigs.logoUrl,
copyright: `Copyright © 2015 - ${new Date().getFullYear()} ${
siteConfigs.credit
}`,
feedLinks: {
rss2: `${siteConfigs.fqdn}/feed.xml`,
json: `${siteConfigs.fqdn}/feed.json`,
atom: `${siteConfigs.fqdn}/atom.xml`,
},
author: author,
});
allPostsNewToOld.forEach((post) => {
feed.addItem({
id: siteConfigs.fqdn + post.path,
title: post.title,
link: siteConfigs.fqdn + post.path,
description: post.description,
image: getPostOGImage(post.socialImage),
author: [author],
contributor: [author],
date: new Date(post.date),
// content: post.body.html,
});
});
writeFileSync('./public/feed.xml', feed.rss2());
writeFileSync('./public/atom.xml', feed.atom1());
writeFileSync('./public/feed.json', feed.json1());
}
```
修改 src/pages/index.tsx,將上面的 generateRSS() function 放進 getStaticProps 裡,在 pnpm build 時就會執行它來生成 RSS 檔案:
```
// ...
// 新增這行 Import
import generateRSS from '@/lib/generateRSS';
// ...
export const getStaticProps: GetStaticProps<Props> = () => {
// ...
// 新增下面這行
generateRSS();
return { props: { posts } };
};
// ...
```
修改 src/pages/_app.tsx,在全站加入 meta data 標註 RSS Feed 的路徑:
```
// ...
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class">
<DefaultSeo
// ...
additionalLinkTags={[
{
rel: 'icon',
href: siteConfigs.logoPath,
},
// 加入下面這兩個 link tag
{
rel: 'alternate',
type: 'application/rss+xml',
href: '/feed.xml',
},
{
rel: 'alternate',
type: 'application/atom+xml',
href: '/atom.xml',
},
]}
/>
<LayoutWrapper>
<Component {...pageProps} />
</LayoutWrapper>
</ThemeProvider>
);
}
export default MyApp;
```
修改 .gitignore、.eslintignore、.prettierignore,忽略生成的 RSS Feed:
```
# ...
# 加入下面這 3 條規則
# RSS related files (generated by generateRSS.js)
/public/atom.xml
/public/feed.xml
/public/feed.json
```
使用 pnpm build 產生 RSS 檔案
```
public
├ atom.xml
├ feed.json
└ feed.xml
```
# 為內文小標題加入 anchor 錨點連結
安裝 rehype-slug
```
pnpm add rehype-slug
```
修改 contentlayer.config.ts,將 rehype-slug 加入 rehypePlugins 列表:
```
// ...
import rehypeSlug from 'rehype-slug';
// ...
export default makeSource({
// ...
mdx: {
rehypePlugins: [
// 加入 rehypeSlug
rehypeSlug, // For generating slugs for headings
rehypeCodeTitles, // For adding titles to code blocks
[rehypePrism, { ignoreMissing: true }], // For code syntax highlighting
],
},
});
```
使用 CustomHeading 元件顯示 anchor 按鈕
新增 src/components/CustomHeading.tsx:
```
type CustomHeadingProps = React.ComponentPropsWithRef<
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
> & { Component: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' };
function CustomHeading({
Component,
id,
children,
...otherProps
}: CustomHeadingProps) {
return (
<Component
id={id}
className="group scroll-mt-24 whitespace-pre-wrap"
{...otherProps}
>
<span className="mr-3">{children}</span>
<a
href={id && `#${id}`}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-lg text-slate-400 no-underline opacity-0 shadow-sm ring-1 ring-slate-900/5 transition-all hover:bg-slate-100 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 group-hover:opacity-100 dark:text-slate-400 dark:ring-slate-400/20 dark:hover:text-slate-700"
aria-label="Anchor"
>
#
</a>
</Component>
);
}
export const CustomH1 = (props: React.ComponentPropsWithRef<'h1'>) => (
<CustomHeading Component="h1" {...props} />
);
export const CustomH2 = (props: React.ComponentPropsWithRef<'h2'>) => (
<CustomHeading Component="h2" {...props} />
);
export const CustomH3 = (props: React.ComponentPropsWithRef<'h3'>) => (
<CustomHeading Component="h3" {...props} />
);
export const CustomH4 = (props: React.ComponentPropsWithRef<'h4'>) => (
<CustomHeading Component="h4" {...props} />
);
export const CustomH5 = (props: React.ComponentPropsWithRef<'h5'>) => (
<CustomHeading Component="h5" {...props} />
);
export const CustomH6 = (props: React.ComponentPropsWithRef<'h6'>) => (
<CustomHeading Component="h6" {...props} />
);
```
修改 src/lib/mdxComponents.ts,加入 H1 到 H6 的客製化元件:
```
import {
CustomH1,
CustomH2,
CustomH3,
CustomH4,
CustomH5,
CustomH6,
} from '@/components/CustomHeading';
import CustomPre from '@/components/CustomPre';
// Custom components/renderers to pass to MDX.
const mdxComponents = {
h1: CustomH1,
h2: CustomH2,
h3: CustomH3,
h4: CustomH4,
h5: CustomH5,
h6: CustomH6,
pre: CustomPre,
};
export default mdxComponents;
```
結果截圖如下:

# 強化內文連結換頁速度、加入外部連結 icon
加入客製化 <CustomLink/>,強化內文連結換頁速度、加入外部連結 icon
我們來擴充之前 Day 11 全站樣式切版 時就已經新增的 src/components/CustomLink.tsx。
將 src/components/CustomLink.tsx 移動到 src/components/CustomLink/CustomLink.tsx,並修改內容如下:
```
import Link from 'next/link';
import ExternalLinkIcon from './external-link.svg';
type Props = React.ComponentPropsWithoutRef<'a'>;
const CustomLink = ({ href, children, ...rest }: Props) => {
const isInternalLink = href && href.startsWith('/');
const isAnchorLink = href && href.startsWith('#');
if (isInternalLink) {
return (
<Link href={href}>
<a {...rest}>{children}</a>
</Link>
);
}
if (isAnchorLink) {
return (
<a href={href} {...rest}>
{children}
</a>
);
}
return (
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
{children}
{typeof children === 'string' && (
<ExternalLinkIcon className="ml-1 inline-block h-4 w-4" />
)}
</a>
);
};
export default CustomLink;
```
上面的 <CustomLink/> 會判斷 href 來知道連結是連往部落格其他頁面的內部連結、錨點連結、或是外部連結。
針對內部連結會渲染成 Next.js 的 Link,做 client-side 換頁會快速很多。
針對錨點連結不做任何處理,渲染常規 a link。
針對外部連結,則會加入 target="_blank" 以開新分頁方式打開連結,並在連結右邊多加 icon,讓讀者知道這連結會連到外部。
再來加入 src/components/CustomLink/external-link.svg,這個是標注外部連結的 icon:
```
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
```
加入 src/components/CustomLink/index.ts,讓之前就有用到 CustomLink 的地方不用改 import 路徑:
```
import CustomLink from './CustomLink';
export default CustomLink;
```
修改 src/lib/mdxComponents.ts,加入 a 的客製化元件:
```
// ...
import CustomLink from '@/components/CustomLink';
// Custom components/renderers to pass to MDX.
const mdxComponents = {
// ...
a: CustomLink,
};
export default mdxComponents;
```
使用 pnpm dev
# 強化內文連結換頁速度、加入外部連結 icon
我們來擴充之前 Day 11 全站樣式切版 時就已經新增的 src/components/CustomLink.tsx。
將 src/components/CustomLink.tsx 移動到 src/components/CustomLink/CustomLink.tsx,並修改內容如下:
```
import Link from 'next/link';
import ExternalLinkIcon from './external-link.svg';
type Props = React.ComponentPropsWithoutRef<'a'>;
const CustomLink = ({ href, children, ...rest }: Props) => {
const isInternalLink = href && href.startsWith('/');
const isAnchorLink = href && href.startsWith('#');
if (isInternalLink) {
return (
<Link href={href}>
<a {...rest}>{children}</a>
</Link>
);
}
if (isAnchorLink) {
return (
<a href={href} {...rest}>
{children}
</a>
);
}
return (
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
{children}
{typeof children === 'string' && (
<ExternalLinkIcon className="ml-1 inline-block h-4 w-4" />
)}
</a>
);
};
export default CustomLink;
```
再來加入 src/components/CustomLink/external-link.svg,這個是標注外部連結的 icon:
```
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
```
加入 src/components/CustomLink/index.ts,讓之前就有用到 CustomLink 的地方不用改 import 路徑:
```
import CustomLink from './CustomLink';
export default CustomLink;
```
修改 src/lib/mdxComponents.ts,加入 a 的客製化元件:
```
// ...
import CustomLink from '@/components/CustomLink';
// Custom components/renderers to pass to MDX.
const mdxComponents = {
// ...
a: CustomLink,
};
export default mdxComponents;
```
pnpm dev

### 圖片效能最佳化,使用 Next.js Image、plaiceholder、客製 MDX 元件
安裝相關套件
```
pnpm add image-size plaiceholder sharp unist-util-visit
```
允許 Next.js Image 使用 webp, avif 格式
修改 next.config.mjs,加入 images 區塊:
```
import { withContentlayer } from 'next-contentlayer';
/** @type {import('next').NextConfig} */
const nextConfig = withContentlayer({
// ...
// 加入 images 區塊
images: {
// Enable modern image formats
formats: ['image/avif', 'image/webp'],
},
});
export default nextConfig;
```
使用客製 imageMetadata rehype plugin,幫圖片加入長寬屬性和 LQIP
新增 src/plugins/imageMetadata.ts:
```
// Custom rehype plugin to add width and height to local images
// To make Next.js <Image/> works
// Ref: https://kylepfromer.com/blog/nextjs-image-component-blog
// Similiar structure to:
// https://github.com/JS-DevTools/rehype-inline-svg/blob/master/src/inline-svg.ts
import imageSize from 'image-size';
import path from 'path';
import { getPlaiceholder } from 'plaiceholder';
import { Node, visit } from 'unist-util-visit';
import { promisify } from 'util';
const sizeOf = promisify(imageSize);
/**
* An `<img>` HAST node
*/
interface ImageNode extends Node {
type: 'element';
tagName: 'img';
properties: {
src: string;
height?: number;
width?: number;
base64?: string;
};
}
/**
* Determines whether the given HAST node is an `<img>` element.
*/
function isImageNode(node: Node): node is ImageNode {
const img = node as ImageNode;
return (
img.type === 'element' &&
img.tagName === 'img' &&
img.properties &&
typeof img.properties.src === 'string'
);
}
/**
* Filters out non absolute paths from the public folder.
*/
function filterImageNode(node: ImageNode): boolean {
return node.properties.src.startsWith('/');
}
/**
* Adds the image's `height` and `width` to it's properties.
*/
async function addMetadata(node: ImageNode): Promise<void> {
const res = await sizeOf(
path.join(process.cwd(), 'public', node.properties.src)
);
if (!res) throw Error(`Invalid image with src "${node.properties.src}"`);
const { base64 } = await getPlaiceholder(node.properties.src, { size: 10 }); // 10 is to increase detail (default is 4)
node.properties.width = res.width;
node.properties.height = res.height;
node.properties.base64 = base64;
}
/**
* This is a Rehype plugin that finds image `<img>` elements and adds the height and width to the properties.
* Read more about Next.js image: https://nextjs.org/docs/api-reference/next/image#layout
*/
export default function imageMetadata() {
return async function transformer(tree: Node): Promise<Node> {
const imgNodes: ImageNode[] = [];
visit(tree, 'element', (node) => {
if (isImageNode(node) && filterImageNode(node)) {
imgNodes.push(node);
}
});
for (const node of imgNodes) {
await addMetadata(node);
}
return tree;
};
}
```
修改 contentlayer.config.ts,套用上面寫的 imageMetadata rehype plugin:
```
import imageMetadata from './src/plugins/imageMetadata';
// ...
export default makeSource({
// ...
mdx: {
rehypePlugins: [
// ...
imageMetadata, // For adding image metadata (width, height)
],
},
});
```
新增 src/components/CustomImage.tsx:
```
import Image, { ImageProps } from 'next/image';
type Props = ImageProps & { base64?: string };
export default function CustomImage({
src,
height,
width,
base64,
alt,
...otherProps
}: Props) {
if (!src) return null;
if (typeof src === 'string' && (!height || !width)) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} height={height} width={width} alt={alt} {...otherProps} />
);
}
return (
<Image
layout="responsive"
src={src}
alt={alt}
height={height}
width={width}
sizes="(min-width: 40em) 40em, 100vw"
placeholder={base64 ? 'blur' : 'empty'}
blurDataURL={base64}
{...otherProps}
/>
);
}
```
修改 src/lib/mdxComponents.ts,讓 MDX 裡面的 img 都使用 CustomImage 來渲染:
```
import CustomImage from '@/components/CustomImage';
// ...
// Custom components/renderers to pass to MDX.
const mdxComponents = {
// ...
img: CustomImage,
};
export default mdxComponents;
```
### 使用 nprogress 加入換頁進度條
在 Next.js 安裝 nprogress
```
pnpm add nprogress
pnpm add -D @types/nprogress
```
修改 src/pages/_app.tsx:
```
// ...
// 引入 nprogress/nprogress.css
import 'nprogress/nprogress.css';
// ...
// 引入 NProgress、useRouter、useEffect
import { useRouter } from 'next/router';
import NProgress from 'nprogress';
import { useEffect } from 'react';
// 呼叫 NProgress.configure 來初始化
NProgress.configure({ showSpinner: false });
function MyApp({ Component, pageProps }: AppProps) {
// 新增下面這塊 useEffect,在 Next.js 換頁時開始 Nprogress 讀條,並在換頁完成時停止
const router = useRouter();
// Integrate nprogress
useEffect(() => {
router.events.on('routeChangeStart', () => NProgress.start());
router.events.on('routeChangeComplete', () => NProgress.done());
router.events.on('routeChangeError', () => NProgress.done());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ...
}
```
調整進度條顏色
進度條預設是水藍色的,如果要修改的話能寫 css 改顏色。
新增 src/styles/nprogress-custom.scss:
```
#nprogress {
.bar {
@apply h-1 bg-primary-500;
}
.peg {
@apply shadow-[0_0_10px] shadow-primary-500;
}
}
```
修改 src/pages/_app.tsx,在全站引入新的 scss 檔:
```
import '@/styles/globals.css';
import '@/styles/prism-dracula.css';
import '@/styles/prism-plus.css';
import 'nprogress/nprogress.css';
// 新增下面這行引入 nprogress-custom.scss
import '@/styles/nprogress-custom.scss';
// ...
```
pnpm dev 可以看到進度條效果

### 在 MDX 文章側邊加入目錄
安裝 github-slugger 來將標題文字轉成 slug
```
pnpm add github-slugger
pnpm add -D @types/github-slugger
```
將文章原始 MDX 內容傳遞進文章內頁,用來抓取所有標題
修改 src/pages/posts/[slug].tsx,把它傳進文章內頁:
```
// ...
export const getStaticProps: GetStaticProps<Props> = ({ params }) => {
// ...
const post: PostForPostPage = {
title: postFull.title,
date: postFull.date,
description: postFull.description,
path: postFull.path,
socialImage: postFull.socialImage || null,
body: {
code: postFull.body.code,
// 加入下面這行 raw
raw: postFull.body.raw,
},
};
// ...
```
新增 <TableOfContents/> 元件,在文章內頁側邊顯示目錄
新增 src/components/TableOfContents.tsx,目錄的程式邏輯和樣式都在這裡:
```
// ref: https://github.com/ekomenyong/kommy-mdx/blob/main/src/components/TOC.tsx
import clsx from 'clsx';
import GithubSlugger from 'github-slugger';
import { useEffect, useRef, useState } from 'react';
// eslint-disable-next-line no-unused-vars
type UseIntersectionObserverType = (setActiveId: (id: string) => void) => void;
const useIntersectionObserver: UseIntersectionObserverType = (setActiveId) => {
const headingElementsRef = useRef<{
[key: string]: IntersectionObserverEntry;
}>({});
useEffect(() => {
const callback = (headings: IntersectionObserverEntry[]) => {
headingElementsRef.current = headings.reduce((map, headingElement) => {
map[headingElement.target.id] = headingElement;
return map;
}, headingElementsRef.current);
const visibleHeadings: IntersectionObserverEntry[] = [];
Object.keys(headingElementsRef.current).forEach((key) => {
const headingElement = headingElementsRef.current[key];
if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
});
const getIndexFromId = (id: string) =>
headingElements.findIndex((heading) => heading.id === id);
if (visibleHeadings.length === 1) {
setActiveId(visibleHeadings[0].target.id);
} else if (visibleHeadings.length > 1) {
const sortedVisibleHeadings = visibleHeadings.sort(
(a, b) => getIndexFromId(b.target.id) - getIndexFromId(a.target.id)
);
setActiveId(sortedVisibleHeadings[0].target.id);
}
};
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px -70% 0px',
});
const headingElements = Array.from(
document.querySelectorAll('article h2,h3')
);
headingElements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [setActiveId]);
};
type Props = {
source: string;
};
const TableOfContents = ({ source }: Props) => {
const headingLines = source
.split('\n')
.filter((line) => line.match(/^###?\s/));
const headings = headingLines.map((raw) => {
const text = raw.replace(/^###*\s/, '');
const level = raw.slice(0, 3) === '###' ? 3 : 2;
const slugger = new GithubSlugger();
return {
text,
level,
id: slugger.slug(text),
};
});
const [activeId, setActiveId] = useState<string>();
useIntersectionObserver(setActiveId);
return (
<div className="mt-10">
<p className="mb-5 text-lg font-semibold text-gray-900 transition-colors dark:text-gray-100">
目錄
</p>
<div className="flex flex-col items-start justify-start">
{headings.map((heading, index) => {
return (
<button
key={index}
type="button"
className={clsx(
heading.id === activeId
? 'font-medium text-primary-500 hover:text-primary-600 dark:hover:text-primary-400'
: 'font-normal text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200',
heading.level === 3 && 'pl-4',
'mb-3 text-left text-sm transition-colors hover:underline'
)}
onClick={(e) => {
e.preventDefault();
document.querySelector(`#${heading.id}`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest',
});
}}
>
{heading.text}
</button>
);
})}
</div>
</div>
);
};
export default TableOfContents;
```
邏輯大部分是修改自 EkomEnyong.com 部落格的 這隻 TOC.tsx 檔案 的,樣式則參考了 Tailwind Nextjs Starter Blog (repo)。
最後修改 src/components/PostLayout.tsx 來使用它吧:
```
import { useRouter } from 'next/router';
import CustomLink from '@/components/CustomLink';
import PageTitle from '@/components/PageTitle';
import PostBody from '@/components/PostBody';
import TableOfContents from '@/components/TableOfContents';
import formatDate from '@/lib/formatDate';
export interface PostForPostLayout {
date: string;
title: string;
body: { raw: string };
}
// ...
export default function PostLayout({
post,
nextPost,
prevPost,
children,
}: Props) {
const {
date,
title,
body: { raw },
} = post;
const { locale } = useRouter();
return (
<article>
<div className="divide-y divide-gray-200 transition-colors dark:divide-gray-700">
// ...
<div
className="pb-8 transition-colors lg:grid lg:grid-cols-4 lg:gap-x-6"
style={{ gridTemplateRows: 'auto 1fr' }}
>
<div className="divide-y divide-gray-200 pt-10 pb-8 transition-colors dark:divide-gray-700 lg:col-span-3">
<PostBody>{children}</PostBody>
</div>
{/* DESKTOP TABLE OF CONTENTS */}
<aside>
<div className="hidden lg:sticky lg:top-24 lg:col-span-1 lg:block">
<TableOfContents source={raw} />
</div>
</aside>
</div>
// ...
</div>
</article>
);
}
```
使用 pnpm dev,進文章內頁用電腦版瀏覽,就會看到側邊多出目錄了!

### 使用 giscus 在 Next.js 加入留言系統
將 giscus 綁定 Github repo
首先你需要選一個 Public 的 Github repo 來放置留言,如果你在本系列文 Day3 將 Next.js 專案部署上 Vercel 平台 裡面是從 Github repo 來部署上 Vercel 平台的話,你應該已經有 Github repo 了,可以使用它來放置留言。

> 首先你需要進到你 repo 的設定頁

> 然後將 Discussions 功能打開:
接著在你的帳號啟用 [giscus Github App](https://github.com/apps/giscus),點連結進去啟用它:

這樣就完成綁定了
在 giscus 官網驗證綁定狀態,並取得 repo ID 和 category ID
點下面連結進去 giscus 官網:
https://giscus.app/zh-TW
首先複製你的 repo 名稱(username/repo_name)進去,驗證是否已完成 giscus 綁定。
接著選擇要用哪種 Github Discussions 的分類存放留言,這裡我們遵照 giscus 的建議,選擇「Announcements」:

接著畫面往下滑,就會看到程式碼區塊了。
請你複製這邊的 data-repo-id 和 data-category-id,等等在 Next.js 裡會用到:

在 Next.js 裡安裝 @giscus/react,加入留言區塊
安裝 @giscus/react:
```
pnpm add @giscus/react
```
新增 src/configs/giscusConfigs.ts,並填入綁定好的 repo 名稱,和剛剛複製的、repoId、categoryId:
```
export const giscusConfigs = {
repo: 'username/repo_name' as `${string}/${string}`,
repoId: 'R_xxxxxxxxxxx',
category: 'Announcements',
categoryId: 'DIC_xxxxxxxxxxxx',
};
```
新增 src/components/Comment.tsx:
```
import Giscus from '@giscus/react';
import { useTheme } from 'next-themes';
import { giscusConfigs } from '@/configs/giscusConfigs';
const Comment = () => {
const { theme } = useTheme();
return (
<div id="comment" className="mx-auto max-w-prose py-6">
<Giscus
repo={giscusConfigs.repo}
repoId={giscusConfigs.repoId}
category={giscusConfigs.category}
categoryId={giscusConfigs.categoryId}
mapping="pathname"
reactionsEnabled="1"
emitMetadata="0"
inputPosition="top"
theme={theme === 'dark' ? 'transparent_dark' : 'light'}
loading="lazy"
/>
</div>
);
};
export default Comment;
```
修改 src/components/PostLayout.tsx,加入上面的 <Comment/> 元件:
```
import { useRouter } from 'next/router';
import Comment from '@/components/Comment';
// ...
export default function PostLayout({
post,
nextPost,
prevPost,
children,
}: Props) {
// ...
return (
<article>
<div className="divide-y divide-gray-200 transition-colors dark:divide-gray-700">
// ...
<div
className="pb-8 transition-colors lg:grid lg:grid-cols-4 lg:gap-x-6"
style={{ gridTemplateRows: 'auto 1fr' }}
>
<div className="divide-y divide-gray-200 pt-10 pb-8 transition-colors dark:divide-gray-700 lg:col-span-3">
<PostBody>{children}</PostBody>
</div>
{/* DESKTOP TABLE OF CONTENTS */}
<aside>
<div className="hidden lg:sticky lg:top-24 lg:col-span-1 lg:block">
<TableOfContents source={raw} />
</div>
</aside>
</div>
<div className="divide-y divide-gray-200 pb-8 transition-colors dark:divide-gray-700">
<Comment />
// ...
</div>
</div>
</article>
);
}
```
使用 pnpm dev,進文章內頁就會看到最下面多出留言區塊了
最終效果如下:



> 若有新的留言會收到email 留言通知
這篇修改的程式碼如下:
https://github.com/gahgah147/nextjs/tree/day25-giscus-comment
### 使用 kbar 加入 Command Palette 指令面板
安裝 @heroicons/react
我們會給 Command Palette 裡各個選項指定 icon,這裡我們統一使用 Tailwind CSS 官方出的 Heroicons
```
pnpm add @heroicons/react
```
安裝 @tailwindcss/line-clamp
後續切版 Command Palette 時,我們也希望當選項文字太長時,能截斷文字只顯示一行,避免跑版。
這種效果可以使用 CSS 的 -webkit-line-clamp 來實現。
```
pnpm add -D @tailwindcss/line-clamp
```
然後修改 tailwind.config.js 來啟用它:
```
// ...
/** @type {import('tailwindcss').Config} */
module.exports = {
// ...
plugins: [
require('@tailwindcss/typography'),
// 加入 @tailwindcss/line-clamp
require('@tailwindcss/line-clamp'),
],
};
```
安裝 kbar
接著來安裝 Command Palette 主體的 kbar(官網、Github repo):
```
pnpm add kbar
```
實作 Command Palette
使用 kbar 實作它,切版一樣使用 Tailwind CSS。
新增 src/components/CommandPalette/index.ts:
```
import CommandPalette from './CommandPalette';
export default CommandPalette;
```
新增 src/components/CommandPalette/CommandPalette.tsx:
:::info
(如果你的網站有更多頁面、或想要更多可執行操作,可以擴充裡面的 actions array)
:::
```
// template come from:
// https://blog.prototypr.io/how-to-implement-command-palette-with-kbar-and-tailwind-css-71ea0e3f99c1
import {
HomeIcon,
LightBulbIcon,
MoonIcon,
SunIcon,
} from '@heroicons/react/24/outline';
import {
ActionId,
ActionImpl,
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarProvider,
KBarResults,
Priority,
useMatches,
} from 'kbar';
import { useRouter } from 'next/router';
import { useTheme } from 'next-themes';
import React, { forwardRef, useMemo } from 'react';
import { KBarSearch } from './KBarSearch';
type Props = {
children: React.ReactNode;
};
export default function CommandPalette({ children }: Props) {
const router = useRouter();
const { setTheme } = useTheme();
const actions = [
// Page section
{
id: 'home',
name: '首頁',
keywords: 'home homepage index 首頁',
perform: () => router.push('/'),
icon: <HomeIcon className="h-6 w-6" />,
section: {
name: '頁面',
priority: Priority.HIGH,
},
},
// Operation section
// - Theme toggle
{
id: 'theme',
name: '切換主題',
keywords: 'change toggle theme mode color 切換 更換 顏色 主題 模式',
icon: <LightBulbIcon className="h-6 w-6" />,
section: '操作',
},
{
id: 'theme-light',
name: '明亮模式',
keywords: 'theme light white mode color 顏色 主題 模式 明亮 白色',
perform: () => setTheme('light'),
icon: <SunIcon className="h-6 w-6" />,
parent: 'theme',
section: '操作',
},
{
id: 'theme-dark',
name: '暗黑模式',
keywords: 'theme dark black mode color 顏色 主題 模式 暗黑 黑色 深夜',
perform: () => setTheme('dark'),
icon: <MoonIcon className="h-6 w-6" />,
parent: 'theme',
section: '操作',
},
];
return (
<KBarProvider actions={actions}>
<CommandBar />
{children}
</KBarProvider>
);
}
function CommandBar() {
return (
<KBarPortal>
<KBarPositioner className="z-20 flex items-center bg-gray-400/70 p-2 backdrop-blur-sm dark:bg-gray-900/80">
<KBarAnimator className="box-content w-full max-w-[600px] overflow-hidden rounded-xl border border-gray-400 bg-white/80 p-2 dark:border-gray-600 dark:bg-gray-700/80">
<KBarSearch className="flex h-16 w-full bg-transparent px-4 outline-none" />
<RenderResults />
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}
function RenderResults() {
const { results, rootActionId } = useMatches();
return (
<KBarResults
items={results}
onRender={({ item, active }) =>
typeof item === 'string' ? (
<div className="px-4 pt-4 pb-2 font-medium text-gray-500 dark:text-gray-400">
{item}
</div>
) : (
<ResultItem
action={item}
active={active}
currentRootActionId={rootActionId || ''}
/>
)
}
/>
);
}
interface ResultItemProps {
action: ActionImpl;
active: boolean;
currentRootActionId: ActionId;
}
type Ref = HTMLDivElement;
// eslint-disable-next-line react/display-name
const ResultItem = forwardRef<Ref, ResultItemProps>(
(
{
action,
active,
currentRootActionId,
}: {
action: ActionImpl;
active: boolean;
currentRootActionId: ActionId;
},
ref: React.Ref<HTMLDivElement>
) => {
const ancestors = useMemo(() => {
if (!currentRootActionId) return action.ancestors;
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
);
// +1 removes the currentRootAction; e.g.
// if we are on the "Set theme" parent action,
// the UI should not display "Set theme… > Dark"
// but rather just "Dark"
return action.ancestors.slice(index + 1);
}, [action.ancestors, currentRootActionId]);
return (
<div
ref={ref}
className={`${
active
? 'rounded-lg bg-primary-500 text-gray-100'
: 'text-gray-600 dark:text-gray-300'
} flex cursor-pointer items-center justify-between rounded-lg px-4 py-2`}
>
<div className="flex items-center gap-2 text-base">
{action.icon && action.icon}
<div className="flex flex-col">
<div className="line-clamp-1">
{ancestors.length > 0 &&
ancestors.map((ancestor) => (
<React.Fragment key={ancestor.id}>
<span className="mr-3 opacity-70">{ancestor.name}</span>
<span className="mr-3">›</span>
</React.Fragment>
))}
<span>{action.name}</span>
</div>
{action.subtitle && (
<span className="text-sm">{action.subtitle}</span>
)}
</div>
</div>
{action.shortcut?.length ? (
<div aria-hidden className="grid grid-flow-col gap-2">
{action.shortcut.map((sc) => (
<kbd
key={sc}
className={`${
active
? 'bg-white text-teal-500 dark:bg-gray-500 dark:text-gray-200'
: 'bg-gray-200 text-gray-500 dark:bg-gray-600 dark:text-gray-400'
} flex cursor-pointer items-center justify-between rounded-md px-3 py-2`}
>
{sc}
</kbd>
))}
</div>
) : null}
</div>
);
}
);
```
新增 src/components/CommandPalette/KBarSearch.tsx:
```
// Custom KBarSearch component to fix cannot input Chinese issue
// A replacement of KBarSearch component from kbar
// import { KBarSearch } from 'kbar';
// Copied from: https://github.com/timc1/kbar/issues/237#issuecomment-1253691644
import { useKBar, VisualState } from 'kbar';
import React, { useState } from 'react';
export const KBAR_LISTBOX = 'kbar-listbox';
export const getListboxItemId = (id: number) => `kbar-listbox-item-${id}`;
export function KBarSearch(
props: React.InputHTMLAttributes<HTMLInputElement> & {
defaultPlaceholder?: string;
}
) {
const {
query,
searchQuery,
actions,
currentRootActionId,
activeIndex,
showing,
options,
} = useKBar((state) => ({
searchQuery: state.searchQuery,
currentRootActionId: state.currentRootActionId,
actions: state.actions,
activeIndex: state.activeIndex,
showing: state.visualState === VisualState.showing,
}));
const [search, setSearch] = useState(searchQuery);
const ownRef = React.useRef<HTMLInputElement>(null);
const { defaultPlaceholder, ...rest } = props;
React.useEffect(() => {
query.setSearch('');
ownRef.current!.focus();
return () => query.setSearch('');
}, [currentRootActionId, query]);
React.useEffect(() => {
query.setSearch(search);
}, [query, search]);
const placeholder = React.useMemo((): string => {
const defaultText = defaultPlaceholder ?? 'Type a command or search…';
return currentRootActionId && actions[currentRootActionId]
? actions[currentRootActionId].name
: defaultText;
}, [actions, currentRootActionId, defaultPlaceholder]);
return (
<input
{...rest}
ref={ownRef}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
autoComplete="off"
role="combobox"
spellCheck="false"
aria-expanded={showing}
aria-controls={KBAR_LISTBOX}
aria-activedescendant={getListboxItemId(activeIndex)}
value={search}
placeholder={placeholder}
onChange={(event) => {
props.onChange?.(event);
setSearch(event.target.value);
options?.callbacks?.onQueryChange?.(event.target.value);
}}
onKeyDown={(event) => {
props.onKeyDown?.(event);
if (currentRootActionId && !search && event.key === 'Backspace') {
const parent = actions[currentRootActionId].parent;
query.setCurrentRootAction(parent);
}
}}
/>
);
}
```
最後修改 src/pages/_app.tsx,用 <CommandPalette> 元件包住整個 App:
```
// ...
import CommandPalette from '@/components/CommandPalette';
// ...
function MyApp({ Component, pageProps }: AppProps) {
// ...
return (
<ThemeProvider attribute="class">
// 用 <CommandPalette> 包住整個 App
<CommandPalette>
// ...
<LayoutWrapper>
<Component {...pageProps} />
</LayoutWrapper>
</CommandPalette>
</ThemeProvider>
);
}
export default MyApp;
```
在 navigation 加入 Command Palette 按鈕
新增 src/components/CommandPaletteToggle.tsx:
```
import { useKBar } from 'kbar';
export default function CommandPaletteToggle() {
const { query } = useKBar();
return (
<button
aria-label="Toggle Command Palette"
type="button"
className="hidden h-12 w-12 rounded py-3 px-4 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 sm:block"
onClick={query.toggle}
>
<svg
fill="none"
className="h-4 w-4 text-gray-900 transition-colors dark:text-gray-100"
viewBox="0 0 18 18"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M14.333 1a2.667 2.667 0 0 0-2.666 2.667v10.666a2.667 2.667 0 1 0 2.666-2.666H3.667a2.667 2.667 0 1 0 2.666 2.666V3.667a2.667 2.667 0 1 0-2.666 2.666h10.666a2.667 2.667 0 0 0 0-5.333Z"
/>
</svg>
</button>
);
}
```
修改 src/components/Header.tsx,加入 <CommandPaletteToggle />:
```
import CommandPaletteToggle from '@/components/CommandPaletteToggle';
// ...
export default function Header() {
return (
<header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60">
<SectionContainer>
<div className="flex items-baseline justify-between">
// ...
<div className="flex items-center text-base leading-5 sm:gap-1">
// ...
<ThemeSwitch />
// 加入 <CommandPaletteToggle />
<CommandPaletteToggle />
<MobileNav />
</div>
</div>
</SectionContainer>
</header>
);
}
```
Troubleshooting
在前面的 src/components/CommandPalette/KBarSearch.tsx,裡面有用到 TypeScript 的 Non-null assertion operator。
如果你發現那邊有 TypeScript Eslint 的 warning 的話,可以修改 .eslintrc.js,把這條 rule 關掉:
```
module.exports = {
// ...
overrides: [
{
files: '**/*.{ts,tsx}',
// ...
rules: {
// 加入下面這行關掉 warning
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
],
};
```
使用 pnpm dev,進去網站裡按下 Ctrl + K (Windows) 或 Cmd + K (Mac),或是點右上角的 Command icon,就能開啟 Command Palette 了。
裡面目前能執行的操作有三個:瀏覽首頁、切換深色主題、切換明亮主題。
最終效果如下:

這篇修改的程式碼如下:
https://github.com/gahgah147/nextjs/tree/day26-command-palette
### 實作 kbar Command Palette 文章搜尋功能
新增 src/components/CommandPalette/getCommandPalettePosts.ts:
```
import { allPostsNewToOld } from '@/lib/contentLayerAdapter';
export type PostForCommandPalette = {
slug: string;
title: string;
path: string;
};
export const getCommandPalettePosts = (): PostForCommandPalette[] => {
const commandPalettePosts = allPostsNewToOld.map((post) => ({
slug: post.slug,
title: post.title,
path: post.path,
}));
return commandPalettePosts;
};
```
新增 src/components/CommandPalette/useCommandPalettePostActions.tsx:
```
import { useRegisterActions } from 'kbar';
import { useRouter } from 'next/router';
import { PostForCommandPalette } from './getCommandPalettePosts';
export const useCommandPalettePostActions = (
posts: PostForCommandPalette[]
): void => {
const router = useRouter();
useRegisterActions(
posts.map((post) => ({
id: post.slug,
name: post.title,
perform: () => router.push(post.path),
section: '搜尋文章',
parent: 'search-posts',
})),
[]
);
};
```
修改 src/pages/index.tsx:
```
// ...
import {
getCommandPalettePosts,
PostForCommandPalette,
} from '@/components/CommandPalette/getCommandPalettePosts';
import { useCommandPalettePostActions } from '@/components/CommandPalette/useCommandPalettePostActions';
// ...
type Props = {
posts: PostForIndexPage[];
commandPalettePosts: PostForCommandPalette[];
};
export const getStaticProps: GetStaticProps<Props> = () => {
const commandPalettePosts = getCommandPalettePosts();
// ...
return { props: { posts, commandPalettePosts } };
};
const Home: NextPage<Props> = ({ posts, commandPalettePosts }) => {
useCommandPalettePostActions(commandPalettePosts);
// ...
};
// ...
```
修改 src/pages/posts/[slug].tsx:
```
// ...
import {
getCommandPalettePosts,
PostForCommandPalette,
} from '@/components/CommandPalette/getCommandPalettePosts';
import { useCommandPalettePostActions } from '@/components/CommandPalette/useCommandPalettePostActions';
// ...
type Props = {
post: PostForPostPage;
prevPost: RelatedPostForPostLayout;
nextPost: RelatedPostForPostLayout;
commandPalettePosts: PostForCommandPalette[];
};
// ...
export const getStaticProps: GetStaticProps<Props> = ({ params }) => {
const commandPalettePosts = getCommandPalettePosts();
// ...
return {
props: {
post,
prevPost,
nextPost,
commandPalettePosts,
},
};
};
const PostPage: NextPage<Props> = ({
post,
prevPost,
nextPost,
commandPalettePosts,
}) => {
useCommandPalettePostActions(commandPalettePosts);
// ...
};
// ...
```
最後修改 src/components/CommandPalette/CommandPalette.tsx,在 actions array 加入 Search section:
```
import {
MagnifyingGlassIcon,
// ...
} from '@heroicons/react/24/outline';
// ...
export default function CommandPalette({ children }: Props) {
// ...
const actions = [
// Page section
// ...
// 加入這個 section
// Search section
// - Search posts
{
id: 'search-posts',
name: '文章',
keywords:
'search find posts writing words blog articles thoughts 搜尋 尋找 文章 寫作 部落格',
icon: <MagnifyingGlassIcon className="h-6 w-6" />,
section: '搜尋',
},
// Operation section
// - Theme toggle
// ...
];
return (
<KBarProvider actions={actions}>
<CommandBar />
{children}
</KBarProvider>
);
}
// ...
```
使用 pnpm dev,進去網站裡按下 Ctrl + K (Windows) 或 Cmd + K (Mac),或是點右上角的 Command icon,開啟 Command Palette 後,就會看到多出「搜尋文章」的操作可以執行了!

## 使用 next-i18next 實作中英文多語系
安裝 next-i18next
輸入指令安裝套件:
```
pnpm add next-i18next
```
新增 next-i18next.config.js:
```
module.exports = {
i18n: {
locales: ['en', 'zh-TW'],
defaultLocale: 'zh-TW',
},
};
```
修改 next.config.mjs,啟用 next-i18next:
```
// ...
import i18nConfig from './next-i18next.config.js';
const { i18n } = i18nConfig;
/** @type {import('next').NextConfig} */
const nextConfig = withContentlayer({
// ...
i18n,
});
export default nextConfig;
```
修改 src/pages/_app.tsx,用 appWithTranslation 包住整個 App:
```
// ...
import { appWithTranslation } from 'next-i18next';
import nextI18nConfig from '../../next-i18next.config';
// ...
// Explicitly pass nextI18nConfig to suppress i18next console warning
// `react-i18next:: You will need to pass in an i18next instance by using initReactI18next`
// Ref: https://github.com/i18next/next-i18next/issues/718#issuecomment-1190468800
export default appWithTranslation(MyApp, nextI18nConfig);
```
我們這裡會新增 en 和 zh-TW 這兩個語系,以及分成兩個 namespace:全站共用的 common 和首頁專屬的 indexPage。
因此我們需要新增下面四個檔案:
public/locales/en/common.json
public/locales/en/indexPage.json
public/locales/zh-TW/common.json
public/locales/zh-TW/indexPage.json
新增 public/locales/en/common.json:
```
{
"copied": "Copied!",
"table-of-contents": "Table of contents",
"home": "Home",
"posts": "Posts",
"search": "Search",
"search-posts": "Search Posts",
"next-article": "Next Article",
"previous-article": "Previous Article",
"published-time": "Published time",
"toggle-theme": "Toggle theme",
"dark-mode": "Dark mode",
"light-mode": "Light mode",
"page": "Page",
"operation": "Operation",
"toggle-language": "Toggle language",
"english": "English",
"chinese": "中文"
}
```
新增 public/locales/en/indexPage.json:
```
{
"latest-posts": "Latest Posts",
"intro-title": "Hey,I am Iron Man ?",
"intro-1": "I'm Tony Stark,not Stank!",
"intro-2": "I'm rich and have saved the world lots of times.",
"intro-3": "I have aliens, purple things, and purple aliens."
}
```
新增 public/locales/zh-TW/common.json:
```
{
"copied": "已複製!",
"table-of-contents": "目錄",
"home": "首頁",
"posts": "文章",
"search": "搜尋",
"search-posts": "搜尋文章",
"next-article": "下一篇文章",
"previous-article": "上一篇文章",
"published-time": "發佈時間",
"toggle-theme": "切換主題",
"dark-mode": "暗黑模式",
"light-mode": "明亮模式",
"page": "頁面",
"operation": "操作",
"toggle-language": "切換語言",
"english": "English",
"chinese": "中文"
}
```
新增 public/locales/zh-TW/indexPage.json:
```
{
"latest-posts": "最新文章",
"intro-title": "Hey,I am Iron Man ?",
"intro-1": "我是 Tony Stark,不是 Stank!",
"intro-2": "老子很有錢,拯救過很多次世界。",
"intro-3": "我討厭外星人、紫色的東西、和紫色外星人。"
}
```
在各頁面引用語系檔案
修改 src/pages/index.tsx:
```
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
// ...
export const getStaticProps: GetStaticProps<Props> = async (context) => {
const locale = context.locale!;
// ...
return {
props: {
...(await serverSideTranslations(locale, ['indexPage', 'common'])),
// ...
},
};
};
// ...
```
新增 src/configs/i18nConfigs.ts:
```
export const LOCALES = ['en', 'zh-TW'];
export const DEFAULT_LOCALE = 'zh-TW';
```
修改 src/pages/posts/[slug].tsx:
```
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { ParsedUrlQuery } from 'querystring';
import { LOCALES } from '@/configs/i18nConfigs';
// ...
interface Params extends ParsedUrlQuery {
slug: string;
}
export const getStaticPaths: GetStaticPaths = () => {
const paths: string[] = [];
LOCALES.forEach((locale) => {
paths.push(...allPosts.map((post) => `/${locale}${post.path}`));
});
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps<Props, Params> = async (
context
) => {
const { slug } = context.params!;
const locale = context.locale!;
// ...
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
// ...
},
};
};
// ...
```
在 Header 加入語系切換按鈕
接著來加入下圖這個語系切換按鈕:
新增 src/components/LanguageSwitch.tsx:
```
/* eslint-disable jsx-a11y/anchor-is-valid */
import Link from 'next/link';
import { useRouter } from 'next/router';
const LanguageSwitch = () => {
const router = useRouter();
const { pathname, query } = router;
const nextLocale = router.locale === 'en' ? 'zh-TW' : 'en';
return (
<Link locale={nextLocale} href={{ pathname, query }}>
<a
aria-label="Toggle Language"
className="rounded p-2 text-2xl leading-6 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 sm:p-3"
>
{router.locale === 'en' ? '??' : '??'}
</a>
</Link>
);
};
export default LanguageSwitch;
```
修改 src/components/Header.tsx,顯示 LanguageSwitch:
```
import LanguageSwitch from '@/components/LanguageSwitch';
// ...
export default function Header() {
return (
<header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60">
<SectionContainer>
<div className="flex items-baseline justify-between">
// ...
<div className="flex items-center text-base leading-5 sm:gap-1">
// ...
// 加入 LanguageSwitch
<LanguageSwitch />
<ThemeSwitch />
<CommandPaletteToggle />
<MobileNav />
</div>
</div>
</SectionContainer>
</header>
);
}
```
在 Command Palette 指令面板加入語系切換選單
修改 src/components/CommandPalette/CommandPalette.tsx,加入 language section:
```
import {
// ...
LanguageIcon,
} from '@heroicons/react/24/outline';
import { useTranslation } from 'next-i18next';
// ...
export default function CommandPalette({ children }: Props) {
const { t } = useTranslation(['common']);
// ...
const actions = [
// ...
// - Language toggle
{
id: 'language',
name: t('toggle-language'),
keywords:
'change toggle locale language translation 切換 更換 語言 語系 翻譯',
icon: <LanguageIcon className="h-6 w-6" />,
section: t('operation'),
},
];
// ...
}
// ...
```
新增 src/components/CommandPalette/useCommandPaletteLocaleActions.tsx:
```
import { useRegisterActions } from 'kbar';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
export const useCommandPaletteLocaleActions = () => {
const router = useRouter();
const { pathname, asPath, query } = router;
const { t } = useTranslation(['common']);
const changeLocale = (locale: string) => {
router.push({ pathname, query }, asPath, { locale: locale });
};
useRegisterActions(
[
{
id: 'language-english',
name: 'English',
keywords: 'locale language translation english 語言 語系 英文 英語',
perform: () => changeLocale('en'),
icon: <span className="p-1">??</span>,
parent: 'language',
section: t('operation'),
},
{
id: 'language-chinese',
name: '中文',
keywords:
'locale language translation traditional chinese taiwanese 語言 語系 翻譯 中文 台灣 繁體',
perform: () => changeLocale('zh-TW'),
icon: <span className="p-1">??</span>,
parent: 'language',
section: t('operation'),
},
],
[asPath]
);
};
```
新增 src/components/LayoutPerPage.tsx,用來在每個頁面呼叫 useCommandPaletteLocaleActions:
```
import { useCommandPaletteLocaleActions } from '@/components/CommandPalette/useCommandPaletteLocaleActions';
type Props = {
children: React.ReactNode;
};
const LayoutPerPage = ({ children }: Props) => {
useCommandPaletteLocaleActions();
return <>{children}</>;
};
export default LayoutPerPage;
```
接著將每個頁面用 <LayoutPerPage/> 包住。
修改 src/pages/index.tsx:
```
// ...
const Home: NextPage<Props> = ({ posts, commandPalettePosts }) => {
// ...
return (
<LayoutPerPage>
// ...
</LayoutPerPage>
);
};
// ...
```
修改 src/pages/posts/[slug].tsx:
```
// ...
const PostPage: NextPage<Props> = ({
// ...
}) => {
// ...
return (
<LayoutPerPage>
// ...
</LayoutPerPage>
);
};
// ...
```
這樣就成功把切換語系 action 加入 Command Palette 了。
讓 giscus 留言區塊支援多語系
修改 src/components/Comment.tsx:
```
import { useRouter } from 'next/router';
// ...
const Comment = () => {
// ...
const { locale } = useRouter();
return (
<div id="comment" className="mx-auto max-w-prose py-6">
<Giscus
// ...
lang={locale}
/>
</div>
);
};
// ...
```
把所有頁面和元件文字換成 i18n key
最後把每個頁面和元件的文字換成 i18n key,就完成多語系處理了。
修改 src/components/Header.tsx,把 CustomLink 內的文字用 t function 轉換成翻譯後文字:
```
import { useTranslation } from 'next-i18next';
// ...
export default function Header() {
const { t } = useTranslation(['common']);
return (
<header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60">
<SectionContainer>
<div className="flex items-baseline justify-between">
// ...
<div className="flex items-center text-base leading-5 sm:gap-1">
<div className="hidden gap-1 sm:flex">
{headerConfigs.navLinks.map((link) => (
<CustomLink
key={link.title}
href={link.href}
className="rounded p-3 font-medium text-gray-900 transition-colors hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800"
>
{t(link.title)} // <-- 修改這一行
</CustomLink>
))}
</div>
// ...
</div>
</div>
</SectionContainer>
</header>
);
}
```
修改 src/components/CustomPre.tsx,一樣把文字改成 t function:
```
// ...
function CustomPre({ children, className, ...props }: Props) {
// ...
return (
<div className="group relative">
<pre
{...props}
ref={preRef}
className={clsx(className, 'focus:outline-none')}
>
<div className="absolute top-0 right-0 m-2 flex items-center rounded-md bg-[#282a36] dark:bg-[#262626]">
<span
className={clsx('hidden px-2 text-xs text-green-400 ease-in', {
'group-hover:flex': copied,
})}
>
{t('copied')} // <-- 修改這一行
</span>
</div>
{children}
</pre>
</div>
);
}
// ...
```

> 有遇到錯誤訊息
官方是提供以下解決方法
npx @next/codemod new-link . --force
這樣就完成了!使用 pnpm dev,進去網站後按下 Header 的切換語系按鈕,或用 Command Palette 切換語系,就能在中英文語系切換,看到不同語系的網站了!.
最終效果如下:



需要排除以下問題
Error: Multiple children were passed to <Link> with `href` of `/posts/post-with-code` but only one child is supported
### Next.js Contentlayer blog 舊路徑轉址
實作舊路徑轉址
首先修改 contentlayer.config.ts,在 Post 新增 redirectFrom 屬性,這樣就能在每篇文章 .mdx 裡設定各自的轉址規則:
```
// ...
export const Post = defineDocumentType(() => ({
// ...
fields: {
// ...
// 新增 redirectFrom
redirectFrom: {
type: 'list',
of: { type: 'string' },
},
},
// ...
}));
// ...
```
新增 src/lib/getAllRedirects.ts:
```
import { allPosts } from '@/lib/contentLayerAdapter';
import { unifyPath } from '@/lib/unifyPath';
export type Redirect = {
source: string;
destination: string;
permanent: boolean;
};
export const getAllRedirects = () => {
const redirects: Redirect[] = [];
allPosts.forEach((post) => {
const allRedirectFrom =
post.redirectFrom?.map((from) => unifyPath(from)) || [];
allRedirectFrom.forEach((from) => {
redirects.push({
source: from,
destination: post.path,
permanent: false,
});
});
});
return redirects;
};
export const allRedirects = getAllRedirects();
```
新增 src/lib/unifyPath.ts:
```
import { allPosts } from '@/lib/contentLayerAdapter';
import { unifyPath } from '@/lib/unifyPath';
export type Redirect = {
source: string;
destination: string;
permanent: boolean;
};
export const getAllRedirects = () => {
const redirects: Redirect[] = [];
allPosts.forEach((post) => {
const allRedirectFrom =
post.redirectFrom?.map((from) => unifyPath(from)) || [];
allRedirectFrom.forEach((from) => {
redirects.push({
source: from,
destination: post.path,
permanent: false,
});
});
});
return redirects;
};
export const allRedirects = getAllRedirects();
```
新增 src/lib/stringifyCatchAllDynamicRoute.ts:
```
export const stringifyCatchAllDynamicRoute = (
route: string | string[] | undefined
): string => {
if (!route) return '';
if (Array.isArray(route)) return route.join('/');
return route;
};
```
最後修改所有用到動態參數 Dynamic Routes 的 pages,目前只有文章內頁 [slug].tsx 需要修改。
修改 src/pages/posts/[slug].tsx:
```
// ...
import { allRedirects } from '@/lib/getAllRedirects';
import { unifyPath } from '@/lib/unifyPath';
// ...
export const getStaticProps: GetStaticProps<Props, Params> = async (
context
) => {
const { slug } = context.params!;
const locale = context.locale!;
// 新增下面這段重導向規則
// Handle redirect logic
const path = unifyPath('/posts/' + slug);
const matchedRedirectRule = allRedirects.find((rule) => rule.source === path);
if (matchedRedirectRule) {
return {
redirect: {
destination: matchedRedirectRule.destination,
permanent: matchedRedirectRule.permanent,
},
};
}
// ...
};
// ...
```
以及新增 src/pages/[...pathToRedirectFrom].tsx,捕捉所有其他路徑:
```
import { GetStaticPaths, GetStaticProps } from 'next';
import { ParsedUrlQuery } from 'querystring';
import { allRedirects, Redirect } from '@/lib/getAllRedirects';
import { stringifyCatchAllDynamicRoute } from '@/lib/stringifyCatchAllDynamicRoute';
import { unifyPath } from '@/lib/unifyPath';
interface Params extends ParsedUrlQuery {
pathToRedirectFrom: string | string[];
}
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps<any, Params> = (context) => {
const { pathToRedirectFrom } = context.params!;
// Handle redirect logic
const path = unifyPath(stringifyCatchAllDynamicRoute(pathToRedirectFrom));
const matchedRedirectRule: Redirect | undefined = allRedirects.find(
(rule) => rule.source === path
);
if (matchedRedirectRule) {
return {
redirect: {
destination: matchedRedirectRule.destination,
permanent: matchedRedirectRule.permanent,
},
};
}
return {
notFound: true,
};
};
const NullComponent = () => null;
export default NullComponent;
```
設定各文章轉址規則
現在就能使用前面在 Contentlayer Post 新增的 redirectFrom 屬性來指定轉址規則。
例如我把 content/posts/20220904-custom-link-demo.mdx 設定如下:
```
---
// ...
type: Post
slug: custom-link-demo
redirectFrom:
- /old-custom-link/
- /2022/08/01/custom-link/
- /posts/old-custom-link/
---
Content...
```
slug 定義了文章現在的路徑,會是 http://localhost:3000/posts/custom-link-demo
而 redirectFrom 指定了下列 3 條舊路徑,都會導向最新文章路徑:
http://localhost:3000/old-custom-link/
http://localhost:3000/2022/08/01/custom-link/
http://localhost:3000/posts/old-custom-link/
使用 pnpm dev,瀏覽你設定過的舊路徑,就能看你會直接重導向到正確的文章路徑,依然能順利瀏覽文章!