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创建博文数据

通过 Github Action 检测到 issue 的 opened
状态,会自动触发工作流
github action 的 ci 配置代码如下:
name: Sync Post
# Controls when the workflow will run
on:
# schedule:
# - cron: "30 1 * * *"
issues:
types:
- opened
- closed
- labeled
workflow_dispatch:
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
jobs:
Publish:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Git config 🔧
run: |
git config --global user.name "xxx"
git config --global user.email "xxx@outlook.com"
- name: Display runtime info ✨
run: |
echo '当前目录:'
pwd
- name: Install 🔧
run: yarn install
- name: Update blog files ⛏️
run: |
yarn sync-post # 主要脚本
git add .
git commit -m 'chore(ci): blog sync'
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复制

由于 中文的文章标题会有问题,这里我们通过 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 项目的工作流执行情况。
当部署成功后,访问我们的博客网址,就可以看到博文了。比如 https://www.giscafer.com/blog/tuopubianjiqijishufangan

因为公开的项目,任何人都可以创建 issues,所以如果是公开的项目,这里无法控制别人提交issue。这个可以考虑简单处理:在生成 mdx 的脚本中,判断是否为本人创建的 issue,如果不是本人,就过滤掉即可。
// 只查询自己的issues,避免别人创建的也更新到博客
issueInstance.listIssues({ creator: 'giscafer' })复制
总结
日后会考虑对接语雀文档,持续改进,欢迎交流!
个人博客地址 :https://www.giscafer.com/
源码仓库:https://github.com/giscafer/blog