# 前言 這篇是參考 鐵人賽 系列文章 「從零開始打造炫砲個人部落格」系列簡介 - 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/ ![](https://hackmd.io/_uploads/Bk5OCfPC3.png) 2.選擇 Add NEW ... -> Project ![](https://hackmd.io/_uploads/r1s30zPC2.png) > 這邊直接有 Template 可以 clone ![](https://hackmd.io/_uploads/SJkDCMDRn.png) > 這樣就創建好了 # Github CI/CD 當我們做完剛剛那套流程後,我們也完成 CI/CD 的設定了!未來當你 push code 到專案 repo 上時,Vercel 都會自動偵測到,並執行新一輪部署,將最新的 code 部署上去。 ![](https://hackmd.io/_uploads/B15WxmwAh.png) # 專案基礎設定 ## 新增 .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://hackmd.io/_uploads/S1YvCe_0h.png) >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 ![](https://hackmd.io/_uploads/ByzCfW_0h.png) ## 排序文章,使用 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://hackmd.io/_uploads/By8U64nC3.png) >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 ``` ![](https://hackmd.io/_uploads/ByCJB1lJ6.png) >執行 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; ``` 結果截圖如下: ![](https://hackmd.io/_uploads/rJC1-LW1T.png) # 強化內文連結換頁速度、加入外部連結 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 ![](https://hackmd.io/_uploads/HkEJKOWJT.png) ### 圖片效能最佳化,使用 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 可以看到進度條效果 ![](https://hackmd.io/_uploads/S1Bel5b1p.png) ### 在 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,進文章內頁用電腦版瀏覽,就會看到側邊多出目錄了! ![](https://hackmd.io/_uploads/ryh679Zya.png) ### 使用 giscus 在 Next.js 加入留言系統 將 giscus 綁定 Github repo 首先你需要選一個 Public 的 Github repo 來放置留言,如果你在本系列文 Day3 將 Next.js 專案部署上 Vercel 平台 裡面是從 Github repo 來部署上 Vercel 平台的話,你應該已經有 Github repo 了,可以使用它來放置留言。 ![](https://hackmd.io/_uploads/SyuZHobJ6.png) > 首先你需要進到你 repo 的設定頁 ![](https://hackmd.io/_uploads/rJdNHjW1p.png) > 然後將 Discussions 功能打開: 接著在你的帳號啟用 [giscus Github App](https://github.com/apps/giscus),點連結進去啟用它: ![](https://hackmd.io/_uploads/rJ9KBiW1a.png) 這樣就完成綁定了 在 giscus 官網驗證綁定狀態,並取得 repo ID 和 category ID 點下面連結進去 giscus 官網: https://giscus.app/zh-TW 首先複製你的 repo 名稱(username/repo_name)進去,驗證是否已完成 giscus 綁定。 接著選擇要用哪種 Github Discussions 的分類存放留言,這裡我們遵照 giscus 的建議,選擇「Announcements」: ![](https://hackmd.io/_uploads/SyuvDEHyT.png) 接著畫面往下滑,就會看到程式碼區塊了。 請你複製這邊的 data-repo-id 和 data-category-id,等等在 Next.js 裡會用到: ![](https://hackmd.io/_uploads/rk_tdNH1a.png) 在 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,進文章內頁就會看到最下面多出留言區塊了 最終效果如下: ![](https://hackmd.io/_uploads/SyPU8BBy6.png) ![](https://hackmd.io/_uploads/B1KKUSS16.png) ![](https://hackmd.io/_uploads/SJMxDHryT.png) > 若有新的留言會收到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://hackmd.io/_uploads/SyUHccSy6.png) 這篇修改的程式碼如下: 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 後,就會看到多出「搜尋文章」的操作可以執行了! ![](https://hackmd.io/_uploads/BJfzkiHk6.png) ## 使用 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> ); } // ... ``` ![](https://hackmd.io/_uploads/r12qtCDkp.png) > 有遇到錯誤訊息 官方是提供以下解決方法 npx @next/codemod new-link . --force 這樣就完成了!使用 pnpm dev,進去網站後按下 Header 的切換語系按鈕,或用 Command Palette 切換語系,就能在中英文語系切換,看到不同語系的網站了!. 最終效果如下: ![](https://hackmd.io/_uploads/BJqT6m_yT.png) ![](https://hackmd.io/_uploads/Bkv1RmO1p.png) ![](https://hackmd.io/_uploads/HkybAQukp.png) 需要排除以下問題 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,瀏覽你設定過的舊路徑,就能看你會直接重導向到正確的文章路徑,依然能順利瀏覽文章!