如何用Typescript、MDX和Tailwind CSS建立一个静态的Next.js Markdown博客

800 阅读9分钟

用Typescript、MDX和Tailwind CSS构建静态的Next.js Markdown博客

Next.js是一个建立在React.js之上的框架。React是一个客户端渲染库,一切都在用户浏览器的客户端进行渲染。Next.js为React引入了服务器端渲染。

首先,它是用于服务器端渲染的,你可以在服务器端渲染组件并将其展示给用户。Next.js进一步支持静态网站的生成。这意味着你可以轻松地建立服务器端渲染的应用程序和静态网站生成的应用程序。

在本指南中,我们将用Typescript、MDX和Tailwind CSS构建一个静态Next.js标记文件博客。我们将讨论使用这个堆栈从头开始构建整个应用程序的技术。最后,我们将为CI/CD Vercel部署设置整个栈。

先决条件

要继续学习本教程,必须具备以下条件。

  • 在你的系统上安装[Node.js]。
  • 使用React、Next.js和TypeScript的基本知识。
  • 对Git和GitHub的基本了解。

为什么是Next.js?

使用Next.js构建的应用程序因其具有的一些很酷的功能而变得强大。这包括。

热代码重载

当你在处理你的应用程序时,它将在侧面快速重新加载,你不必刷新页面来查看变化。

静态文件服务

它可以帮助你提供任何静态文件,如图片、图标、Robots.txt、.html 等。

快速

Next.js为我们完成了繁重的工作。它加载提前生成的HTML文件等文件。这意味着,一旦用户请求一个网页,它就会立即被加载和提供。这发生得很快,因为网站是静态的,而生成器在构建时产生网页。

与多个造型框架的集成

有了Next.js,你可以用JJSX CSS、less、sass、Tailwind、CSS模块或任何其他样式框架开始样式设计。

支持TypeScript

你不需要安装任何特别的东西来使Typescript与Next.js一起工作。Typescript是JavaScript的超集和静态类型版本。Next.js提供了一个一次性的create-next-app 命令,帮助你启动和加载TypeScript支持的模板。

即使你把你的文件从.js 改为.ts ,它的作用也是一样的。

创建一个基本的Next.js TypeScript应用程序

Next.js中的create-next-app 命令允许你获取一个引导的Next.js应用程序。为了设置Next.js应用程序,我们将使用Next.js团队提供的一个工具来抽象出设置应用程序的过程。

我们将使用create next app来简化Next.js应用程序的铺设过程。

选择你想保存项目的文件夹,然后运行下面的命令。

npx create-next-app --typescript .

这将返回这个Next.js应用程序的设置过程。--typescript 参数将指定该应用程序将使用TypeScript。

添加软件包

MDX是一个markdown组件,可以让你轻松地对文本内容进行样式设计。通过markdown,你可以编写任何内容,如插入粗体、斜体、图像等。

MDX将使你能够在你的markdown文件内集成组件,并在网页上渲染它们。MDX与基于组件的框架(如React.js或Next.js)配对良好。

这样,我们就可以用MDX来建立一个博客,并管理这些文章的整个生命周期。为了建立一个MDX博客应用,根据我们所使用的技术栈,我们将需要以下软件包。

  • [Gray matter]:Gray matter用于解析来自文件或字符串的前述内容。

要与Tailwind CSS一起使用。

  • [Post CSS]:Post CSS是一个风格化的开发工具,使用JavaScript模块。我们将用它来转换MDX的CSS。
  • [Auto Prefixer]:一个基于Post CSS的库,用于解析CSS并向CSS规则添加供应商前缀。
  • [@tailwind/typography]:提供classes ,可用于从我们的组件中生成漂亮的排版默认值(比如从Markdown生成的HTML)。

要安装上述软件包,请运行以下命令。

npm i gray-matter next-mdx-remote tailwindcss postcss autoprefixer @tailwindcss/typography

我们还需要设置Post CSS和Tailwind CSS。运行下面的命令,生成tailwindpostcss 配置文件。

npx tailwindcss init -p

打开tailwind.config.js 文件,并做如下修改。

module.exports = {
  plugins: [require('@tailwindcss/typography')],
  purge: ['./components/**/*.tsx', './pages/**/*.tsx'],
  variants: {},
  theme: {
    extend: {},
  },
  darkMode: false, 
};

这个配置将设置purge 选项,以便从生产环境中删除未使用的样式。插件@tailwindcss/typography 允许你使用预设样式的类。

为了在我们的应用程序中利用这些tailwind配置,我们需要把这个导入到pages/_app.tsx ,如图所示。

import 'tailwindcss/tailwind.css';

设置实用程序

由于我们将与Markdown文件打交道,因此需要高效的实用程序功能。这将帮助我们执行一些任务,如获取帖子,获取单个帖子,以及获取帖子项目。

在项目根目录下创建一个新的目录,并将其命名为utils 。在utils 目录下创建一个mdxUtils.ts 文件,如图所示。

import matter from 'gray-matter';
import {join} from 'path';
import fs from 'fs';
import { verify } from 'crypto';

// structure of items
type Items =  {
    // each post has a parameter key that takes the value of a string
    [key: string] : string
}

// structure of a post
type Post = {
    data:{
        // each post has a parameter key that takes the value of a string
        [key: string] : string
    };
    // each post will include the post content associated with its parameter key
    content: string
}

// path to our list of available posts
const POSTS_PATH = join(process.cwd(),'_posts');

// get the file paths of all available list of posts
function getPostsFilePaths(): string[]{
    return (
        // return the mdx file post path
        fs.readdirSync(POSTS_PATH)
        // load the post content from the mdx files
        .filter((path) => /\.mdx?$/.test(path))
    )
}

// getting a single post
export function getPost(slug:string):Post {
    // add path/location to a single post
    const fullPath = join(POSTS_PATH,`${slug}.mdx`);
    // post's content
    const fileContents = fs.readFileSync(fullPath,'utf-8');
    // get the front matter data and content
    const {data,content} = matter(fileContents);
    // return the front matter data and content
    return { data,content};
}

// load the post items
export function getPostItems(filePath:string,fields:string[] = []): Items{
    // create a slug from the mdx file location
    const slug = filePath.replace(/\.mdx?$/,"");
    // get the front matter data and content
    const {data,content} = getPost(slug);

    const items: Items = {};

    // just load and include the content needed
    fields.forEach((field) => {
        // load the slug
        if(field === 'slug'){
            items[field] = slug;
        }
        // load the post content
        if(field === 'content'){
            items[field] = content;
        }
        // check if the above specified field exists on data
        if(data[field]){
            // verify the fileds has data
            items[field] = data[field];
        }
    });
    // return the post items
    return items;
}

// getting all posts
export function getAllPosts(fields: string[]): Items []{
    // add paths for getting all posts 
    const filePaths = getPostsFilePaths();
    // get the posts from the filepaths with the needed fields sorted by date
    const posts = filePaths.map((filePath) => getPostItems(filePath,fields)).sort((post1,post2) => post1.date > post2.date ? 1 : -1);
    // return the available post
    return posts;
}

我们已经创建了诸如getAllPosts()getPostItems()getPost()getPostsFilePaths() 等函数。这样,我们就可以访问markdown文件来读取它们的内容。然后,将这些文件作为博客文章来获取,其路径将允许你获取单个文章或整个可用的文章列表。

设置组件

在项目的根文件夹中创建一个名为components 的目录。在这个components 目录中准备三个脚本。这三个脚本是:Header.tsxThumbnail.tsxLayout.tsx 。每个脚本将包含不同的组件,如下所述。

Header.tsx 脚本将作为导航栏,如下图所示。

// Import the link props
import Link from 'next/link';

// add the React Header Element
const Header: React.FC = () => {

    return (
        // header value
        <header className="py-2">

        <Link href="/">
            <a className="text-2xl font-bold text-green-500">My Simple Blog App</a>
        </Link>
        </header>
    )
}

// export Header module
export default Header;

每个博客基本上都会有一个图片。Thumbnail.tsx ,将把博客文章的图片组件脚本化,如下图的代码块所述。我们将利用Image 组件,它在Next.js中渲染图像时可以顺利工作。

// import link artifacts
import Link from 'next/link';
// import image artifacts
import Image from 'next/image';

// Thumbnail properties
type Props = {
    // Thumbnail title
    title: string;
    // Thumbnail image src
    src: string;
    // Thumbnail slug link
    slug?:string;
}

const Thumbnail: React.FC<Props> = ({ title, src, slug}: Props) => {
  // Add the Thumbnail cover image
    const image = (
        <Image
        height={720}
        width={1280}
        src={src}
        alt={`Thumbnail cover image ${title}`}
        />
    );

    // return the Thumbnail cover image slug
    return (
        <>
            {slug ? (
                <Link href={`/posts/${slug}`}>
                <a aria-label={title}>{image}</a>
                </Link>
            ) : (
                image
            )}
        </>
    )
}

// export Thumbnail module
export default Thumbnail;

这个应用程序中每个页面的布局都将存储在Layout.tsx 。每个页面都将有我们上面设置的Header

import Header from './Header';

type Props = {
    children: React.ReactNode;
}

const Layout: React.FC<Props> = ({ children }: Props) => {
    return (
        <>
            <div className="max-w-prose mx-auto px-4">
                <Header />
                <main className="pt-4 pb-12">{children}</main>
            </div>
        </>
    )
}

export default Layout;

我们调用Header ,然后附加动态页面内容,这些内容将在children 部分下。为了将上述布局应用于所有页面,我们将对pages/_app.tsx 文件做如下修改。

import Layout from '../components/Layout';
function MyApp({ Component, pageProps }: AppProps) {
    return (
        <Layout>
            <Component {...pageProps} />
        </Layout>
    );
}

你所需要的是导入Layout ,并用Layout 包裹返回的Component

创建一个博客文章

在你的项目根目录下,在_posts 目录下创建一个getting-started.mdx 文件。在getting-started.mdx 文件中,我们将写一个简单的博文,如下。

  • 添加前言部分。
---
date: '2021-11-25'
thumbnail: /assets/getting-started.jpeg
title: Getting started in Next.js with TypeScript
description: A quick guide into Next.js and Typescript with deployment to vercel
prerequisites: ['Node.js installed on your computer', 'Basic knowledge working with Next.js and TypeScript']
stacks: ['Next.js','TypeScript','Git']
---
  • 调用组件。
<Prerequisites />
<Stacks />

使用MDX,你可以使用JSX提供的基于组件的结构。

  • 添加一些额外的内容。下面的内容只是为了演示。你可以根据自己的喜好进行定制。
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
  • unsplash,下载一张你喜欢的图片,然后把它添加到/public/assets/ ,即/public/assets/getting-started.jpeg

我们的博文将遵守上述的结构。按照我们的步骤,尝试创建更多的帖子。

创建类型

由于我们使用的是TypeScript,定义我们的数据结构将是有帮助的。TypeScript支持类型注释,允许你定义你正在处理的数据类型。

在项目根目录下,在types 文件夹中创建一个post.ts 文件,并指定一个帖子的结构,如下图所示。

export interface IPost {
    slug:string;
    date:string;
    thumbnail:string;
    title:string;
    description:string;
    prerequisites:string[];
    stacks:string[];
}

显示所有帖子

为了显示帖子,我们将在pages/index.tsx 文件中工作。编辑你的pages/index.tsx 文件,如下所示。

import Thumbnail from '../components/Thumbnail';
import type { NextPage, GetStaticProps } from 'next'
import { IPost } from "../types/post";
import Link from 'next/link'
import { getAllPosts } from "../utils/mdxUtils";

// props type
type Props = {
  posts: [IPost]
}

// component render function
const Home: NextPage<Props> = ({ posts }: Props) => {
  return (
    <div>
      <h1 className="text-4xl font-bold mb-4">Technical articles</h1>

      <div className="space-y-12">
        {posts.map((post) => (
          <div key={post.slug}>
            <div className="mb-4">
              <Thumbnail
                slug={post.slug}
                title={post.title}
                src={post.thumbnail}
              />
            </div>

            <h2 className="text-2xl font-bold mb-4">
              <Link href={`/posts/${post.slug}`}>
                <a>{post.title}</a>
              </Link>
            </h2>

            <p>{post.description}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

export default Home

// get posts from serverside at build time
export const getStaticProps: GetStaticProps = async () => {
  const posts = getAllPosts([
    'title',
    'slug',
    'date',
    'description',
    'thumbnail'
  ]);

  // retunr the posts props
  return { props: { posts } }
}

我们只是在做以下工作。

  • 在构建时使用getStaticProps() 组件函数从服务器端获取帖子,正如之前在mdxUtils.ts 实用函数中定义的那样。
  • 显示从渲染的组件函数中获取的帖子。

让我们测试一下一切是否工作正常。从你的项目文件夹中,运行以下命令来启动开发服务器。

npm run dev

使用http://localhost:3000 ,在浏览器上打开正在运行的服务器。现在,你应该可以在你的网页上看到添加的MDX内容了。

home-page

显示一个单一的帖子

我们将首先处理应用程序中的状态管理,以管理我们组件的数据。我们将使用Next.js中的Context API。在项目根目录下创建一个context 目录,其中有一个mdxContext.tsx 文件。

编辑你的mdxContext.tsx ,如下图所示。

import {
    createContext,
    useContext,
    useState,
    Dispatch,
    ReactElement,
    ReactNode,
    SetStateAction,
} from 'react';

type ContextProps = {
    prerequisites: string[];
    setPrerequisites: Dispatch<SetStateAction<string[]>>;
    stacks: string[];
    setStacks: Dispatch<SetStateAction<string[]>>;
};

type Props = {
    children: ReactNode;
};

const MdxComponentsContext = createContext({} as ContextProps);

export function MdxComponentsProvider({ children }: Props): ReactElement {
    const [prerequisites, setPrerequisites] = useState < string[] > ([]);
    const [stacks, setStacks] = useState < string[] > ([]);

    return (
        <MdxComponentsContext.Provider
            value={{
                prerequisites,
                setPrerequisites,
                stacks,
                setStacks,
            }}
        >
            {children}
        </MdxComponentsContext.Provider>
    );
}

export function useMdxComponentsContext(): ContextProps {
    return useContext(MdxComponentsContext);
} 

上面的代码块管理着我们的组件的状态。这包括prerequisites ,和stacks 。然后,我们导出Provider (MdxComponentsProvider) 和一个Consumer hook function (useMdxComponentsContext)

下一步是将MdxComponentsProvider 嵌入到pages/_app.tsx 文件中,通过用它包装组件来访问所有页面。

import type { AppProps } from 'next/app';
import Layout from '../components/Layout';
import 'tailwindcss/tailwind.css'
import { MdxComponentsProvider } from '../context/mdxContext';

function MyApp({ Component, pageProps }: AppProps) {
    return (
    <MdxComponentsProvider>
        <Layout>
            <Component {...pageProps} />
        </Layout>
    </MdxComponentsProvider>)
}

export default MyApp

在你的components 文件夹中,再添加两个文件:Prerequisites.tsx ,和Stacks.tsx 。在Prerequisites.tsx 文件中,我们将从消费者钩子中获取prerequisites ,并将它们映射到一个列表中。

添加下面的代码块。

import { useMdxComponentsContext } from "../context/mdxContext";

const Prerequisites: React.FC = () => {
    const prerequisites = useMdxComponentsContext().prerequisites;
    return (
        <>
            <h2>Prerequisites</h2>
            <ol>
                {prerequisites.map((prerequisite, index) => (
                    <li key={index}>{prerequisite}</li>
                ))}
            </ol>
        </>
    )
}

export default Prerequisites;

Stacks.tsx 将从消费者钩子中获取stacks ,并将它们映射到一个列表中,如图所示。

import {useMdxComponentsContext} from "../context/mdxContext";

const Stacks: React.FC = () => {
    const stacks = useMdxComponentsContext().stacks;
    return (
        <>
            <h2>Stacks</h2>
            <ol>
                {stacks.map((stack, index) => (
                <li key={index}>{stack}</li>
                ))}
            </ol>
        </>
    )
}

export default Stacks;

pages 目录中,创建一个posts 文件夹,在它下面有一个[slug].tsx 文件。方括号表示这是一个依赖于slug 关键字的动态文件。

这就是我们要设置的[slug].tsx

import { serialize } from 'next-mdx-remote/serialize';
import { GetStaticProps, GetStaticPaths } from 'next';
import { useEffect } from 'react';
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote';

import { useMdxComponentsContext } from '../../context/mdxContext';
import Thumbnail from '../../components/Thumbnail';
import { IPost } from '../../types/post';
import { getPost, getAllPosts } from '../../utils/mdxUtils';
import Prerequisites from '../../components/Prerequisites';
import { ParsedUrlQuery } from 'querystring';
import Stacks from '../../components/Stacks';

// props type
type Props = {
    source: MDXRemoteSerializeResult,
    frontMatter: Omit<IPost, 'slug'>;
}

// components to render
const components = {
    Prerequisites,
    Stacks,
}

const PostPage: React.FC<Props> = ({ source, frontMatter }: Props) => {

    // get setters
    const { setPrerequisites, setStacks } = useMdxComponentsContext();

    useEffect(() => {
        // set prerequisites
        setPrerequisites(frontMatter.prerequisites);
        // set stacks
        setStacks(frontMatter.stacks);
    }, [
        setPrerequisites,
        setStacks,
        frontMatter.prerequisites,
        frontMatter.stacks
    ]);

    return (
        <div>

            <article className="prose prose-green">
                <div className="mb-4">
                    <Thumbnail title={frontMatter.title} src={frontMatter.thumbnail} />
                </div>

                <h1>{frontMatter.title}</h1>

                <p>{frontMatter.description}</p>

                <MDXRemote components={components} {...source} />
            </article>
        </div>
    )
}

export default PostPage;

interface Iparams extends ParsedUrlQuery {
    slug: string
}

export const getStaticProps: GetStaticProps = async (context) => {

    const { slug } = context.params as Iparams;
    // get the slug
    const { content, data } = getPost(slug);
    // serialize the data on the server side
    const mdxSource = await serialize(content, { scope: data });
    return {
        props: {
            source: mdxSource,
            frontMatter: data
        }
    }
}

export const getStaticPaths: GetStaticPaths = () => {
    //only get the slug from posts 
    const posts = getAllPosts(['slug']);

    // map through to return post paths
    const paths = posts.map((post) => ({
        params: {
            slug: post.slug
        }
    }));

    return {
        paths,
        fallback: false
    }
}

这个动态文件允许你对服务器端和客户端进行如下设置。

在服务器端。

  • 使用getStaticProps() 来获取当前文章的内容。文章的数据被序列化,并以source ,和frontMatter 的形式返回。
  • 在构建时从getStaticPaths() 中获取文章路径。同时,返回fallbackfalse ,这样每个在构建时没有生成的文章路径都会产生一个404 错误。

在客户端。

  • 获取source ,以及从服务器发送的frontMatter
  • 使用消费者钩子将数据设置到应用上下文。
  • 检查页面是否正在构建并返回一个加载文本。
  • 显示文章内容。source ,以及components ,如MDXRemote 组件所示。

确保开发服务器仍在运行,并测试这是否有效。从主页上点击任何一篇文章,应该会有一个单独的文章页面被加载。

specific-article-page

部署到Vercel

要部署到Vercel,确保你首先推送/发布你的代码到GitHub仓库。

登录到你的Vercel仪表盘,如果你没有,可以注册

从Vercel仪表板上选择New Project 。确保你已经登录到你的GitHub账户,选择它作为你的Git提供者,然后搜索并导入你的项目。

输入你喜欢的项目名称,然后点击部署。

vercel-deployment-conf

部署完成后,点击生成的预览。你将被重定向到你的托管博客应用程序,你可以与朋友和一般社区分享。

hosted-blog-application

总结

Next.js是一个了不起的基于React的框架。它允许你处理服务器端和客户端内容的几乎任何方面。它非常轻量级,允许你创建完整的快速应用程序。

在本教程中,我们用Next.js、TypeScript、MDX和Tailwind CSS构建了一个博客应用程序,并将其部署到Vercel。