Next.js搭建静态博客

使用 next.jsnextra 搭建博客失败后,进而尝试next examples 中的 [blog-starter] 搭建,顺便看了遍代码。

原理:博客页面框架需要前端搭建,使用next.js的getStaticProps实现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"
}
}

执行 npm install,安装所需框架后。在Next.js中是约定式路由,pages 文件夹下的文件均是路由组件,因此在根目录(与 package.json 同级的目录)新建 pages 文件夹,然后新建index.tsx文件(本项目支持TypeScript)。这样就相当于应用的'/'路由指向'index.tsx'文件。

我们在首页列出所有的博客,为了生成静态页面,因此使用getStaticProps来获取页面所有数据,这个页面会在应用构建时生成index.html文件。

那页面首页数据是所有的博客数据,我们的博客放在_posts文件下(对于_是约定式前缀),读取 _post下的所有文件,需要一个函数,因此新建一个lib文件夹(同样在根目录),新建文件 api.ts

首先引入node的模块fspath

执行 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代表同步执行。

export function getPostSlugs() {
   return fs.readdirSync(postsDirectory)
}


export function getAllPosts(fields: string[] = []) {
    const slugs = getPostSlugs()
   ...
}

异步执行示例:

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')

使用安装(执行 npm install )并引用(执行 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 中对每一个博客文件执行 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中的无状态组件(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了,会执行安装。

配置文件是我们需要额外添加的。

同样,根目录新建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
          href="https://nextjs.org/"
          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
              href="https://nextjs.org/docs/basic-features/pages"
              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仓库

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352

推荐阅读更多精彩内容