使用
next.js的nextra搭建博客失敗后,進而嘗試next examples 中的 [blog-starter] 搭建,順便看了遍代碼。
原理:博客頁面框架需要前端搭建,使用next.js的getStaticProps實現(xiàn)ssr.
項目的主要依賴,如下所示:
//package.json
{
...
"dependencies": {
"next": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remark": "^14.0.2",
"remark-html": "^15.0.1",
"typescript": "^4.7.4",
"classnames": "^2.3.1",
"date-fns": "^2.28.0",
"gray-matter": "^4.0.3"
}
"devDependencies": {
"@types/node": "^18.0.3",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@autoprefixer": "^10.4.7",
"@postcss": "^8.4.14",
"@tailwindcss": "^3.1.4"
}
}
執(zhí)行 npm install,安裝所需框架后。在Next.js中是約定式路由,pages 文件夾下的文件均是路由組件,因此在根目錄(與 package.json 同級的目錄)新建 pages 文件夾,然后新建index.tsx文件(本項目支持TypeScript)。這樣就相當于應用的'/'路由指向'index.tsx'文件。
我們在首頁列出所有的博客,為了生成靜態(tài)頁面,因此使用getStaticProps來獲取頁面所有數據,這個頁面會在應用構建時生成index.html文件。
那頁面首頁數據是所有的博客數據,我們的博客放在_posts文件下(對于_是約定式前綴),讀取 _post下的所有文件,需要一個函數,因此新建一個lib文件夾(同樣在根目錄),新建文件 api.ts。
首先引入node的模塊fs 與 path。
執(zhí)行 process.cwd() 得到的路徑,是指向 node 應用根目錄的,與
__dirname不同,后者指向文件當前路徑。__dirname在不同文件里,得到的不同的值,而process.cwd()在應用的任何文件任何位置,得到的值都是一致的。
//lib/api.ts
import fs from 'fs''
import { join } from 'path'
const postsDirectory = join(process.cwd(), '_posts')
添加 getPostSlugs 函數,fs.readdirSync是讀取文件夾,Sync代表同步執(zhí)行。
export function getPostSlugs() {
return fs.readdirSync(postsDirectory)
}
export function getAllPosts(fields: string[] = []) {
const slugs = getPostSlugs()
...
}
異步執(zhí)行示例:
export function async getPostSlugs() {
return await fs.readdir(postsDirectory)
}
export function getAllPosts(fields: string[] = []) {
const slugs = await getPostSlugs()
}
接下來獲取單個博客文件的數據,使用 gray-matter庫。
import matter from 'gray-matter'
這是一款可以解析文件的庫,據我所知,幾乎博客站點都會用到它來做文件的解析。官方示例:
---
title: Hello
slug: home
---
<h1>Hello world!</h1>
轉換的數據對象:
{
content: '<h1>Hello world!</h1>',
data: {
title: 'Hello',
slug: 'home'
}
}
獲取數據的函數 getPostBySlug:
export function getPostBySlug(slug: string, fields: string[] = []) {
...//見接下來的代碼
}
使用 path 模塊的 join 得到文件路徑,
const realSlug = slug.replace(/.md$/, '')
const fullPath = join(postsDirectory, `${realSlug}.md`)
使用 fs 模塊的 readFileSync 得到文件內容,
const fileContents = fs.readFileSync(fullPath, 'utf8')
使用安裝(執(zhí)行 npm install )并引用(執(zhí)行 import )的 gray 模塊,
const { data, content } = matter(fileContents)
type Items = {
[key: string]: string
}
const items: Items = {}
//確保導出最少的數據
fields.forEach((field) => {
if (field === 'slug') {
items[field] = realSlug
}
if (field === 'content') {
items[field] = content
}
if (typeof data[field] !== 'undefined') {
items[field] = data[field]
}
})
return items
以上就完成了單個博客文件的讀取。
在getAllPosts 中對每一個博客文件執(zhí)行 getPostBySlug,代碼如下:
export function getAllPosts(fields: string[] = []) {
const slugs = getPostSlugs()
const posts = slugs.map((slug) => getPostBySlug(slug, fields)).sort((post1, post2) => (post1.date > post2.date ? -1 " 1))
return posts
}
這樣博客數據我們都讀取完成了,接下來我們需要在首頁的getStaticProps中添加代碼:
//pages/index.tsx
import { getAllPosts } from '../lib/api';
export const getStaticProps = async () => {
const allPosts = getAllPosts([
'title',
'date',
'slug',
'author',
'coverImage',
'excerpt',
])
return {
props: { allPosts },
}
}
然后首頁的編寫就類似于React中的無狀態(tài)組件(stateless)了。
Head 組件是從 next/head 導出的,其它組件是 components 下的組件。
//pages/index.tsx
import Container from '../components/container'
import MoreStories from '../components/more-stories'
import HeroPost from '../components/hero-post'
import Intro from '../components/intro'
import Layout from '../components/layout'
import { getAllPosts } from '../lib/api'
import Head from 'next/head'
import { CMS_NAME } from '../lib/constants'
import Post from '../interfaces/post'
type Props = {
allPosts: Post[]
}
export default function Index({ allPosts }: Props) {
const heroPost = allPosts[0]
const morePosts = allPosts.slice(1)
return (
<>
<Layout>
<Head>
<title>Next.js Blog Example with {CMS_NAME}</title>
</Head>
<Container>
<Intro />
{heroPost && (
<HeroPost
title={heroPost.title}
coverImage={heroPost.coverImage}
data={heroPost.data}
anthor={heroPost.author}
slug={heroPost.slug}
excerpt={heroPost.excerpt}
/>
)}
</Container>
</Layout>
</>
)
}
同時,項目使用的是tailwindCSS 框架,項目根目錄,新建tailwind.config.js,
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./components/**/*.tsx', './pages/**/*.tsx'],
theme: {
extend: {
colors: {
'accent-1': '#FAFAFA',
'accent-2': '#EAEAEA',
'accent-7': '#333',
success: '#0070f3',
cyan: '#79FFE1',
},
spacing: {
28: '7rem',
},
letterSpacing: {
tighter: '-.04em',
},
lineHeight: {
tight: 1.2,
},
fontSize: {
'5xl': '2.5rem',
'6xl': '2.75rem',
'7xl': '4.5rem',
'8xl': '6.25rem',
},
boxShadow: {
sm: '0 5px 10px rgba(0, 0, 0, 0.12)',
md: '0 8px 30px rgba(0, 0, 0, 0.12)',
},
},
},
plugins: [],
}
與postcss.config.js:
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
tailwindCSS框架依賴postcss庫與autoprefixer庫,前面在package.json文件中已經聲明在devDependencies了,會執(zhí)行安裝。
配置文件是我們需要額外添加的。
同樣,根目錄新建components文件夾,放置應用中可以重用的組件:
//Layout.tsx
import Alert from './alert'
import Footer from './footer'
import Meta from './meta'
type Props = {
preview?: boolean
children: React.ReactNode
}
const Layout = ({ preview, children }: Props) => {
return (
<>
<Meta />
<div className="min-h-screen">
<Alert preview={preview} />
<main>{children}</main>
</div>
<Footer />
</>
)
}
export default Layout
//Container.tsx
type Props = {
children?: React.ReactNode
}
const Container = ({ children }: Props) => {
return <div className="container mx-auto px-5">{children}</div>
}
export default Container
//Intro.tsx
import { CMS_NAME } from '../lib/constants'
const Intro = () => {
return (
<section className="flex-col md:flex-row flex items-center md:justify-between mt-16 mb-16 md:mb-12">
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter leading-tight md:pr-8">
Blog.
</h1>
<h4 className="text-center md:text-left text-lg mt-5 md:pl-8">
A statically generated blog example using{' '}
<a
className="underline hover:text-blue-600 duration-200 transition-colors"
>
Next.js
</a>{' '}
and {CMS_NAME}.
</h4>
</section>
)
}
export default Intro
//Meta.tsx
import Head from 'next/head'
import { CMS_NAME, HOME_OG_IMAGE_URL } from '../lib/constants'
const Meta = () => {
return (
<Head>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicon/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon/favicon-16x16.png"
/>
<link rel="manifest" href="/favicon/site.webmanifest" />
<link
rel="mask-icon"
href="/favicon/safari-pinned-tab.svg"
color="#000000"
/>
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
<meta name="theme-color" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
<meta
name="description"
content={`A statically generated blog example using Next.js and ${CMS_NAME}.`}
/>
<meta property="og:image" content={HOME_OG_IMAGE_URL} />
</Head>
)
}
export default Meta
//Footer.tsx
import Container from './container'
import { EXAMPLE_PATH } from '../lib/constants'
const Footer = () => {
return (
<footer className="bg-neutral-50 border-t border-neutral-200">
<Container>
<div className="py-28 flex flex-col lg:flex-row items-center">
<h3 className="text-4xl lg:text-[2.5rem] font-bold tracking-tighter leading-tight text-center lg:text-left mb-10 lg:mb-0 lg:pr-4 lg:w-1/2">
Statically Generated with Next.js.
</h3>
<div className="flex flex-col lg:flex-row justify-center items-center lg:pl-4 lg:w-1/2">
<a
className="mx-3 bg-black hover:bg-white hover:text-black border border-black text-white font-bold py-3 px-12 lg:px-8 duration-200 transition-colors mb-6 lg:mb-0"
>
Read Documentation
</a>
<a
href={`https://github.com/vercel/next.js/tree/canary/examples/${EXAMPLE_PATH}`}
className="mx-3 font-bold hover:underline"
>
View on GitHub
</a>
</div>
</div>
</Container>
</footer>
)
}
export default Footer
//Alter.tsx
import Container from './container'
import cn from 'classnames'
import { EXAMPLE_PATH } from '../lib/constants'
type Props = {
preview?: boolean
}
const Alert = ({ preview }: Props) => {
return (
<div
className={cn('border-b', {
'bg-neutral-800 border-neutral-800 text-white': preview,
'bg-neutral-50 border-neutral-200': !preview,
})}
>
<Container>
<div className="py-2 text-center text-sm">
{preview ? (
<>
This page is a preview.{' '}
<a
href="/api/exit-preview"
className="underline hover:text-teal-300 duration-200 transition-colors"
>
Click here
</a>{' '}
to exit preview mode.
</>
) : (
<>
The source code for this blog is{' '}
<a
href={`https://github.com/vercel/next.js/tree/canary/examples/${EXAMPLE_PATH}`}
className="underline hover:text-blue-600 duration-200 transition-colors"
>
available on GitHub
</a>
.
</>
)}
</div>
</Container>
</div>
)
}
export default Alert
//Avatar.tsx
type Props = {
name: string
picture: string
}
const Avatar = ({ name, picture }: Props) => {
return (
<div className="flex items-center">
<img src={picture} className="w-12 h-12 rounded-full mr-4" alt={name} />
<div className="text-xl font-bold">{name}</div>
</div>
)
}
export default Avatar
import PostPreview from './post-preview'
import type Post from '../interfaces/post'
type Props = {
posts: Post[]
}
const MoreStories = ({ posts }: Props) => {
return (
<section>
<h2 className="mb-8 text-5xl md:text-7xl font-bold tracking-tighter leading-tight">
More Stories
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-x-16 lg:gap-x-32 gap-y-20 md:gap-y-32 mb-32">
{posts.map((post) => (
<PostPreview
key={post.slug}
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
slug={post.slug}
excerpt={post.excerpt}
/>
))}
</div>
</section>
)
}
export default MoreStories
import Avatar from './avatar'
import DateFormatter from './date-formatter'
import CoverImage from './cover-image'
import Link from 'next/link'
import type Author from '../interfaces/author'
type Props = {
title: string
coverImage: string
date: string
excerpt: string
author: Author
slug: string
}
const PostPreview = ({
title,
coverImage,
date,
excerpt,
author,
slug,
}: Props) => {
return (
<div>
<div className="mb-5">
<CoverImage slug={slug} title={title} src={coverImage} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<Link
as={`/posts/${slug}`}
href="/posts/[slug]"
className="hover:underline"
>
{title}
</Link>
</h3>
<div className="text-lg mb-4">
<DateFormatter dateString={date} />
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
</div>
)
}
export default PostPreview
//CoverImage.tsx
import cn from 'classnames'
import Link from 'next/link'
type Props = {
title: string
src: string
slug?: string
}
const CoverImage = ({ title, src, slug }: Props) => {
const image = (
<img
src={src}
alt={`Cover Image for ${title}`}
className={cn('shadow-sm', {
'hover:shadow-lg transition-shadow duration-200': slug,
})}
/>
)
return (
<div className="sm:mx-0">
{slug ? (
<Link as={`/posts/${slug}`} href="/posts/[slug]" aria-label={title}>
{image}
</Link>
) : (
image
)}
</div>
)
}
export default CoverImage
更多組件代碼可至GitHub倉庫。