8年前,上大学的时候,第一次自己动手搭建自己的博客,也是 markdown,但用的是 Hexo,生成 html 部署到 gh-pages 。几年前,就停用了Hexo,一直用 GitHub issues 来写博客,不是正式的博客网站,逼格不够,SEO 不友好等问题都有,一直想弄一下,但是懒得弄,觉得这种东西按理是自己生成就好了。我写了文章,就自动帮我同步到个人网站或者是语雀、公众号等。

就在昨天,我看到 Github 上一个外国coder,用 Next.js + Notion(相当于国内语雀)来实现自己的博客,从而得到了灵感。于是,我就想 Next.js + Github Issues 自动化博客展示。

技术栈: Next.js/Typescript & 部署在 Vercel。 博客数据来自 github issues 列表

博客原理:通过 ci 监听 issues 变更,自动更新 mdx 文件到项目 data/blog/*.mdx 文件夹中,Vercel 自动化构建更新。

(一)根据issue创建博文数据
Next.js   Github Issue 构建博客解决方案 - 图2


通过 Github Action 检测到 issue 的 opened 状态,会自动触发工作流
Next.js   Github Issue 构建博客解决方案 - 图3

github action 的 ci 配置代码如下:

  1. name: Sync Post
  2. # Controls when the workflow will run
  3. on:
  4. # schedule:
  5. # - cron: "30 1 * * *"
  6. issues:
  7. types:
  8. - opened
  9. - closed
  10. - labeled
  11. workflow_dispatch:
  12. env:
  13. GH_TOKEN: ${{ secrets.GH_TOKEN }}
  14. jobs:
  15. Publish:
  16. runs-on: ubuntu-latest
  17. steps:
  18. - name: Checkout 🛎️
  19. uses: actions/checkout@v2
  20. - name: Git config 🔧
  21. run: |
  22. git config --global user.name "xxx"
  23. git config --global user.email "xxx@outlook.com"
  24. - name: Display runtime info
  25. run: |
  26. echo '当前目录:'
  27. pwd
  28. - name: Install 🔧
  29. run: yarn install
  30. - name: Update blog files ⛏️
  31. run: |
  32. yarn sync-post # 主要脚本
  33. git add .
  34. git commit -m 'chore(ci): blog sync'
  35. git push


CI 执行 了 yarn sync-post脚本,脚本主要是通过 Github Api 去获取指定项目 的 issue 列表,如 giscafer/blog,然后生成 mdx 文件到 next.js 项目工程的 data/blog目录

/* eslint-disable */
const GitHub = require('github-api')
const fs = require('fs-extra')
const path = require('path')
const pinyin = require('pinyin')
const _ = require('lodash')

const gh = new GitHub({
  token: process.env.GH_TOKEN,
})

const blogOutputPath = '../../data/blog'

// get blog list
const issueInstance = gh.getIssues('giscafer', 'blog')

function generateMdx(issue) {
  const { title, labels, created_at, body } = issue
  return `---
  title: ${title}
  publishedAt: ${created_at}
  summary:
  tags: ${JSON.stringify(labels.map(item => item.name))}
---

${body.replace(/<br \/>/g, '\n')}
`
}

function main() {
  const filePath = path.resolve(__dirname, blogOutputPath)

  issueInstance.listIssues().then(({ data }) => {
    let successCount = 0
    fs.ensureDirSync(filePath)
    fs.emptyDirSync(filePath)
    for (const item of data) {
      try {
        const content = generateMdx(item)
        const tempFileName = item.title.replace(/\//g, '&').replace(/、/g, '-').replace(/ - /g, '-').replace(/\s/g, '-')
        const result = pinyin(tempFileName, {
          style: 0,
        })
        const fileName = _.flatten(result).join('')
        fs.writeFileSync(`${filePath}/${fileName}.mdx`, content)
        console.log(`${filePath}/${fileName}.mdx`, 'success')
        successCount++
      } catch (error) {
        console.log(error)
      }
    }
    if (successCount === data.length) {
      console.log('文章全部同步成功!', data.length)
    } else {
      console.log('文章同步失败!失败数量=', data.length - successCount)
    }
  })
}

module.exports = main

image.png

由于 中文的文章标题会有问题,这里我们通过 pinyin的工具库区将汉字转为拼音作为文件名。但文章的标题还是保留的。如上图 mdx 文件的头部内容:

---
  title: 理解 Virtual DOM
  publishedAt: 2019-03-13T02:48:34Z
  tags: ["Review","React"]
---

(二)Next.js 渲染mdx文件为博客

使用 contentlayer模块工具,可以方便得将 mdx 转成可渲染的 json 文件。再配合 next-contentlayer 提供的 withContentlayer 在构建时转换 mdx 资源。

import { defineDocumentType, makeSource, ComputedFields } from 'contentlayer/source-files' // eslint-disable-line
import readingTime from 'reading-time'
import rehypePrism from 'rehype-prism-plus'
import codeTitle from 'remark-code-titles'

const imgReg = new RegExp(/https:\/\/(.*)\.(png|jpeg|gif|svg|jpg)/)

const getCoverImg = doc => {
  const { raw } = doc.body

  const match = raw.match(imgReg)
  if (match) {
    return match[0]
  }
  return '/blog/default/image.png'
}

const getSlug = doc => {
  const name = doc._raw.sourceFileName.replace(/\.mdx$/, '')
  return name
}

const computedFields: ComputedFields = {
  slug: {
    type: 'string',
    resolve: doc => getSlug(doc),
  },
  image: {
    type: 'string',
    resolve: doc => getCoverImg(doc),
    // resolve: doc => `/blog/${getSlug(doc)}/image.png`,
  },
  og: {
    type: 'string',
    resolve: doc => `/blog/${getSlug(doc)}/og.png`,
  },
  readingTime: { type: 'json', resolve: doc => readingTime(doc.body.raw) },
}

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.mdx`,
  bodyType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    summary: { type: 'string', required: true },
    publishedAt: { type: 'string', required: true },
    updatedAt: { type: 'string', required: false },
    tags: { type: 'json', required: false },
  },
  computedFields,
}))

export default makeSource({
  contentDirPath: 'data/blog',
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [rehypePrism],
    remarkPlugins: [codeTitle],
  },
})

next.config.ts文件为

const { withContentlayer } = require('next-contentlayer') // eslint-disable-line

module.exports = withContentlayer()({
  webpack5: true,
  images: {
    domains: [
      'user-images.githubusercontent.com',
      'files.mdnice.com',
      'cdn.nlark.com',
      'wpimg.wallstcn.com',
      'github.com',
      'giscafer.com',
      'ww1.sinaimg.cn',
    ],
    formats: ['image/avif', 'image/webp'],
  },
  webpack: (config, { isServer }) => {
    if (isServer) {
      require('./scripts/generate-sitemap') // eslint-disable-line
      require('./scripts/generate-rss') // eslint-disable-line
    }

    return config
  },
})

接着 使用 useMDXComponent来渲染 mdx 内容

import { useMDXComponent } from 'next-contentlayer/hooks'

// 省略其他
const Component = useMDXComponent(post.body.code);// code 即为 mdx 文本内容
// 使用组件(conponents 参数支持自定义页面)
<Component components={components} />

(三)利用 Vercel 部署

Vercel 会监听 Github 参考代码变动,一旦变动,就会自动构建部署,这就是所谓的 CI/CD 这个过程。对于个人而言,Vercel 也是免费的。

在上面我们通过 github action 自动监听 issues 变化,就会触发 mdx 文件自动生成提交到 blog 仓库。代码变化之后 Vercel 自动构建部署。所以最终我们就可以看到 issue 的文章显示在博客网站上了

如下图是 Vercel 部署的 blog 项目的工作流执行情况。
image.png

当部署成功后,访问我们的博客网址,就可以看到博文了。比如 https://www.giscafer.com/blog/tuopubianjiqijishufangan

image.png

因为公开的项目,任何人都可以创建 issues,所以如果是公开的项目,这里无法控制别人提交issue。这个可以考虑简单处理:在生成 mdx 的脚本中,判断是否为本人创建的 issue,如果不是本人,就过滤掉即可。

// 只查询自己的issues,避免别人创建的也更新到博客
  issueInstance.listIssues({ creator: 'giscafer' })

总结

日后会考虑对接语雀文档,持续改进,欢迎交流!

个人博客地址 :https://www.giscafer.com/
源码仓库: https://github.com/giscafer/blog