yuque-blog开发日志

如何用语雀和NextJS打造一个完美的博客

我们团队在今年从Trello转移到了Notion,无论从项目进度管理还是内部知识管理,都是非常好用的工具。我非常喜欢在里面进行写作,这样比较容易随时随地进行编辑。但是在国内,Notion的访问比较慢,并且有功能限制。我希望在国内也能找到一款这样的工具。经过一番搜索,找到了语雀

最近NextJS出了一个非常酷的功能,叫做SSG(Server-Side Generation)。基本上可以理解为是静态生成页面,类似同样使用React的Gatsby。NextJS的作者ijjk同时发布了一个叫做notion-blog的项目,这个项目用了NextJS的SSG功能,并且作者逆向爬取了Notion的API接口读取数据,生成了一个博客模板。这时我心里想,如果在国内也能这么玩就好了(其实是想,要是Notion能早点出API就好了哈哈)。

这时候,我找到了语雀的官方API,我觉得我也可以用NextJS做一个yuque-blog的项目,然后这样就可以通过语雀发布博客了。

项目搭建

首先用create-next-app创建项目,然后添加所需的依赖。这里我只需要用到以下几个依赖:

  "dependencies": {
    "dotenv": "^8.2.0",
    "isomorphic-unfetch": "^3.0.0",
    "moment-timezone": "^0.5.31",
    "next": "9.4.0",
    "node-fetch": "^2.6.0",
    "react": "16.13.1",
    "react-dom": "16.13.1",
    "react-is": "^16.13.1",
    "sanitize-html": "^1.24.0",
    "styled-components": "^5.1.0"
  },

另外需要创建我们需要创建next.config.js来注入语雀的API Token。这里我为了避免把token写入代码仓库,用了dotenv来读取.env文件里面的YUQUE_TOKEN环境变量,来进行本地开发。部署的时候只需要在部署环境添加这个环境变量即可。

// next.config.js

require('dotenv').config();

module.exports = {
    env: {
        yuqueToken: process.env.YUQUE_TOKEN,
    },
};
# .env

YUQUE_TOKEN=<你的语雀API Token>

搭建JS项目的环境永远都是最繁琐的,但是我们团队已经用NextJS多年,把其他项目的轮子拉过来拼了拼,加上所需的诸如TypeScript,eslint,prettier等工具。然后终于可以开始写代码了!

语雀API模块

这个模块主要是为了进一步封装语雀的API。找了一圈没有找到非常好的TS实现,但是写起来也不复杂,这里节选测试用的hello接口我们看一下封装。

// utils/yuque-api.ts

import 'isomorphic-unfetch';

const API_ROOT = 'https://www.yuque.com/api/v2';

export interface YuquePayload<T> {
  data: T;
}

export interface HelloMessage {
  message: string;
}

export class YuqueApi {
  private token: string;

  private headers: { [key: string]: string };

  constructor(token: string) {
    this.token = token;
    this.headers = {
      'Content-Type': 'application/json',
      'User-Agent': 'yuque-blog',
      'X-Auth-Token': this.token,
    };
  }

  public async hello(): Promise<YuquePayload<HelloMessage>> {
    const { data } = await this.getResult<HelloMessage>('/hello');
    return {
      data,
    };
  }

  private async getResult<T>(path: string, options: RequestInit = {}): Promise<YuquePayload<T>> {
    const response = await fetch(`${API_ROOT}${path}`, {
      method: 'GET',
      headers: this.headers,
      ...options,
    });
    return response.json();
  }
}

这样我们就可以很快地添加所需要的接口了。这里需要做的接口是用户GET接口、知识库列表GET接口、文档列表GET接口和单篇文档GET接口。

首页列表

首页列表比较简单,我们只需要找到对应的blog知识库(通过slug对应上),然后列出里面已经发布过的文章就可以了。这里我们用getServerSideProps方法即可,原因我在后面会解释。

// pages/index.tsx

import React, { FC } from 'react';
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import Head from 'next/head';

import { YuqueApi, Repo, Doc } from '../utils/yuque-api';

interface HomePageProps {
  repo: Repo;
  docs: Doc[];
}

const HomePage: FC<HomePageProps> = ({ repo, docs }: HomePageProps) => (
  <>
    <Head>
      <title>{repo.name}</title>
    </Head>
    <div>
      <h3>博客列表</h3>
      <ul id="posts">
        {docs.map((doc) => (
          <li key={doc.slug}>
            <Link href={`/posts/${doc.slug}`}>
              <a>
                {doc.title}
              </a>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  </>
);

export default HomePage;

export const getServerSideProps: GetServerSideProps = async (): Promise<{ props: HomePageProps }> => {
  const api = new YuqueApi(process.env.yuqueToken);

  const { data: currentUser } = await api.getUser();
  const { data: repos } = await api.getRepos(currentUser.login);
  const [blogRepo] = repos.filter((repo) => repo.slug === 'blog');
  const { data: docs } = await api.getDocs(blogRepo.namespace);

  return {
    props: {
      repo: blogRepo,
      docs: docs.filter((doc) => doc.status === 1),
    },
  };
};

文章页面

在制作文章页面的时候我遇到了一些坑,但是整体还算比较容易绕过。首先我们创建pages/posts/[slug].tsx文件,这样我们就可以通过读取语雀API里面文档的slug字段来确认路径。这个页面我希望在构建的时候启用SSG,所以我需要使用getStaticPathsgetStaticProps这两个方法。首先编写getStaticPaths

// pages/posts/[slug].tsx

import { GetStaticPaths, GetStaticProps } from 'next';

import { YuqueApi, Doc } from '../../utils/yuque-api';

export const getStaticPaths: GetStaticPaths = async () => {
  const api = new YuqueApi(process.env.yuqueToken);

  const { data: currentUser } = await api.getUser();
  const { data: repos } = await api.getRepos(currentUser.login);
  const [blogRepo] = repos.filter((repo) => repo.slug === 'blog');
  const { data: docs } = await api.getDocs(blogRepo.namespace);

  const paths: Array<{ params: { [key: string]: string | string[] } }> = docs
    .filter((doc) => doc.status === 1)
    .map((doc) => ({ params: { namespace: blogRepo.namespace, slug: doc.slug } }));

  return {
    paths,
    fallback: true,
  };
};

这里请注意最后返回值的fallback: true部分,因为我们时常会进入语雀写作,这里我想希望博客可以随时拉取新的文章内容,所以打开fallback模式就可以让NextJS自行构建对应的文章并缓存。这个也是为什么在首页列表使用getServerSideProps的原因。以下我们添加getStaticProps方法:

// pages/posts/[slug].tsx

interface PostPageProps {
  doc: Doc;
}

export const getStaticProps: GetStaticProps = async ({ params: { namespace, slug } }): Promise<{ props: PostPageProps }> => {
  const api = new YuqueApi(process.env.yuqueToken);

  let _namespace = namespace as string;

  if (!_namespace) {
    const { data: currentUser } = await api.getUser();
    const { data: repos } = await api.getRepos(currentUser.login);
    const [blogRepo] = repos.filter((repo) => repo.slug === 'blog');

    _namespace = blogRepo.namespace;
  }

  const { data: doc } = await api.getDoc(_namespace, slug as string);

  return {
    props: {
      doc,
    },
  };
};

这里我们需要注意,由于fallback模式的存在,有可能我们没有namespace字段(因为这个字段只通过getStaticPaths获取,在fallback模式只有从URL传入的slug),所以我们需要做一个判断并且在没有namespace的情况下主动获取。

最后我们添加一下渲染的React代码:

import React, { FC } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';

const PostPage: FC<PostPageProps> = ({ doc }: PostPageProps) => {
  const router = useRouter();

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  let __html = doc.body_html;

  return (
    <>
      <Head>
        <title>{`${doc.title} - ${doc.book.name}`}</title>
      </Head>
      <div dangerouslySetInnerHTML={{ __html }} />
    </>
  );
};

因为我们在语雀里面编写,语雀只生成了自研的lake格式和html。所以这里我们用dangerouslySetInnerHTML来注入语雀生成的页面了。到这里我们基本上已经做好一个雏型了。运行的时候我们可以看到所有的文章列表,也能够看到语雀编辑的文字。代码格式也保留得很好。不过图片由于Referrer问题无法显示。不过没关系,我们这边也能想办法绕过。

图片渲染

图片渲染我做了一个小测试,在浏览器直接打开URL是可以访问的。所以这里我们只需要直接从服务器端拉取就可以了。这里我们创建一个NextJS API接口pages/api/img/[...slug].ts,并且添加如下代码:

// pages/api/img/[...slug].ts

import fetch from 'node-fetch';

const CDN_ROOT = '/api/img';

export default async (req, res) => {
  const {
    query: { slug },
  } = req;

  const response = await fetch(`${CDN_ROOT}/${slug.join('/')}`);
  const buffer = await response.buffer();

  res.statusCode = 200;
  res.setHeader('Content-Type', 'image/png');
  res.end(buffer);
};

这个API接口会直接根据路径的所有slug,对应去语雀的CDN下载图片并返回。我们只需要把CDN_ROOT在博客HTML里面替换成/api/img即可:

// pages/posts/[slug].tsx

let __html = doc.body_html.replace(CDN_ROOT, '/api/img');

CSS

最后为了方便处理CSS,我把多余的HTML和CSS标记用sanitize-html这个库去掉了。这样我们就可以拿到比较干净的HTML。

// pages/posts/[slug].tsx

import sanitizeHtml from 'sanitize-html';

let __html = doc.body_html.replace(CDN_ROOT, '/api/img');

__html = sanitizeHtml(__html, {
  allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
});

到这里我们的博客的主要功能部分就完成了。最后我在一些比较流行的静态博客系统找了一下主题,最后用了 https://github.com/LukasJoswiak/etch 这个主题来搭建。这样我们的语雀博客系统就完成了。

最终整个yuque-blog源码可以在 https://github.com/xanthous-tech/yuque-blog 找到。

部署

部署我用了NextJS的亲爸爸Vercel(之前是now.sh)来部署。整个流程比较顺畅,构建就算是需要对接语雀的API也能在10秒左右构建好。Vercel的CDN也比较多,在香港也有CDN,所以在国内的速度并不慢。但是就算在国内,也可以通过serverless方式部署到阿里云或者腾讯云,具体部署方法我在yuque-blog的README里面给出了链接,有兴趣的朋友可以试试。

总结

整个项目我前后花了5-7个小时完成(搭建JS项目花了2个小时。。)。我对这个最终的结果还是非常满意的。语雀提供的Webhook功能也能顺利对接Vercel的Deploy Hook,每次文章有更新和发布的时候,Vercel就会触发一次部署更新内容,非常方便。项目的框架也可以为一些CMS功能为主网站提供一个新思路,我们终于可以有一个舒服的写作环境,稳定便宜的部署环境,和足够的扩展性。

如果对这个库有什么疑问和修改,欢迎提交issue和PR:https://github.com/xanthous-tech/yuque-blog