我一直使用静态页面生成器来创建我的博客。但是这种方式在形式上和写代码没什么太大的区别,因为这两者都需要坐在电脑前、打开 VSCode 或者别的什么 IDE后才能编写文章,写完后还要推送到 Github 仓库,然后等待 CI/CD 流程自动部署。
在我的工作和 VSCode 深度绑定的现在,打开 VSCode 对我来说已经是一种负担了(笑)。如果说工作时打开 VSCode 是再正常不过的一件事,那么在偏向个人和日常的博客写作场景下再使用 VSCode,就显得有点命苦(笑)。
刚好之前使用 Vuepress 搭的博客有点看腻了,打算换博客框架。借着这个机会,研究一下如何搭建一个心智负担更小的博客工作流。
1 技术选型
首先要明确需求:
- 我不希望打开 VSCode 或者别的什么 IDE 来写文章;
- 能随时随地写作;
- 至少有一处能在我控制之下的文件副本;
- 免费。
其实加上这几条限制之后,能用的产品就不多了。首先仓库托管肯定是 Github,部署肯定是 Cloudflare Pages。
博客框架我从 Vuepress 切换到了 Astro。相较于前者,Astro 的自定义程度更高、主题也更多。同时非常感谢 @Dnkk2 制作的 Litos,简洁、优雅、美观。在 Litos 优秀设计的基础上,我进行了大量的改造,以适配博客结构。
「不使用 VSCode 等 IDE 来写作」这个需求还是很容易满足的,Obsidian、Notion、飞书等都能满足需求。但是 Obsidian 不能同步(同步功能需要付费),尽管有 Github 插件可以实现同步,该插件不能在移动端使用;飞书更偏向企业协作。而 Notion 更偏向于个人的同时,还提供了专用的 SDK 和 API 导出指定的文章。还有一个加分项是它提供了和 ChatGPT 的连接器。结合这些优势,我最终选择了 Notion 作为写作窗口。
2 初步工作流设计
虽然每一步都有现成的轮子可以用,但最大的难题在于如何把轮子拼成车。在经过了三天的研究、试错和开发后,最后得到的工作流是这样的:
- 在 Notion 上完成写作;
- 触发 Github Action 通过 Notion API 拉取文章;
- 在 Github Action 完成语法转换和文件保存,然后提交到 Git 仓库中;
- Cloudflare Pages 检测到提交行为,自动编译为静态页面并部署
这里我按照实现难度从低到高的顺序来写。
2.1 自动部署
这一步的难度几乎为零。Cloudflare Pages 完全傻瓜化,只需要点点点,就能把 Github 仓库中的 Astro 应用编译成静态页面发布,实在没什么可讲。
2.2 自动拉取文章
自动拉取文章基本只能靠 Github Action 了。原本的构想是自动、定时拉取。例如,在 Github 仓库根目录下维护一个.notion-sync.yml文件:
- last_sync_time: YYYY-MM-DD hh:mm:ss
- entities:
-
name: alice
last_edited_time: YYYY-MM-DD hh:mm:ss
-
name: bob
last_edited_time: YYYY-MM-DD hh:mm:ss
然后读取上次同步时间、再读取数据库中所有文章的最后一次编辑时间、检索最后一次编辑晚于上次同步的文章、再更新文章。
但是这样会存在很多边界问题,例如:
- 检索到待更新的文章,但是由于各种原因拉取失败怎么办?
- 更新文章需要时间,如果我在更新的过程中编辑了文章怎么办?
- …
而且我并不是时时刻刻都在写文章的,这也就意味着绝大部分时间定时拉取都在空转。尽管 Github Action 和 Notion API 不要钱,但还是不要浪费这些公共资源比较好。
于是我开始研究手动同步。为了把心智负担降低到最小,我打算每次只指定一篇文章来同步,这样就完全杜绝了上述的边界问题,而且还不浪费,控制细粒度也精细了不少。
这在 Github Action 上是完全可行的,它支持在运行工作流前手动输入变量。大概类似于这样:
on:
workflow_dispatch:
inputs:
pageId:
description: 'Notion page ID (32 chars without hyphens)'
required: true
Notion Page ID 很方便就可以得到,就是 URL 的后边跟着的那串 32 位 UUID,当然是去掉了连字符之后的。
然后在仓库里创建一个用于拉取文章的脚本:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- ...
- name: Sync page
run: pnpm tsx scripts/syncNotion.ts
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_PAGE_ID: ${{ inputs.pageId }}
在 Github Action 运行工作流时就要求输入 Page ID:
于是这一步也打通。
2.3 字段映射
我特别喜欢 Notion 的数据库功能。借助数据库可以统一管理所有文章的字段,也就是所有文章的元数据和对应 Markdown 文件的 Frontmatter。
结合需求,我设计了以下字段并映射:
| Notion 数据库字段 | Frontmatter 字段 | 备注 |
|---|---|---|
title |
title |
文章标题 |
description |
description |
文章描述 |
category |
无 | 文章分类,用于把文章渲染到不同的页面上 |
tags |
tags |
文章的标签 |
completed |
completed |
文章是否已经写完 |
top |
top |
文章是否置顶 |
create_time |
createTime |
文章的创建时间 |
last_edited_time |
lastEditedTime |
文章的最后一次编辑时间 |
id |
无 | 文章的UUID |
2.4 语法转换
这是最难的一步了。尽管标准 Markdown 语法在各种地方都支持,但是在非标准 Markdown 上,各家的语法就五花八门了。例如我想添加 Callout 块当做提示容器,Notion 直接导出到 Markdown 是这样的:
<aside>
...
<aside>
但是 Litos 主题的语法是这样的:
> [!tip]
> ...
再比如 Litos 的增强代码块:
// title='src/utils/math.ts' ins={2, 5, 8} del={1, 4, 7} mark={10, 11, 13-16}
const add = (a: number, b: number): number => a + b
add(1, 3)
const subtract = (a: number, b: number): number => a - b
subtract(3, 1)
const multiply = (a: number, b: number): number => a * b
multiply(2, 3)
const divise = (a: number, b: number): number => a / b
add(6, 3)
const pow = (a: number, b: number): number => a ** b
add(3, 3)
const mod = (a: number, b: number): number => a % b
mod(10, 3)
Notion 根本不支持。
这一步我选择开源项目 notion_to_md 进行语法转换。借助它的自定义转换器,可以任何将 Notion 块转换为我想要的格式:
function installCustomTransformer(n2m: NotionToMarkdown, notion: Client) {
n2m.setCustomTransformer('code', async (block: any) => {
return parseEnhanceCodeBlock(block, n2m)
})
n2m.setCustomTransformer('callout', async (block: any) => {
return parseCalloutBlock(block, n2m, notion)
})
}
由于 Notion 的 Callout 没有类型的问题,这里将读取第一行作为类型。例如一个 Callout 块的内容是:
// tip 温馨提示
不要在空腹的情况下进食
就会被解析为:
> [!tip] 温馨提示
> 不要在空腹的情况下进食
花了点时间编写这两种转换器,终于是把这一步走通了。
Enjoy it!
3 更进一步
现在的触发方式虽然已经很好,但是这个过程还不够轻量无感:我还需要复制文章 ID、打开 Github、输入 ID 后运行工作流。
最理想的方式应该是借助 Notion 提供的 Button 和 Webhook 功能来做。借助这两个功能以及 Cloudflare Workers,就能实现点击页面上的按钮发布文章,整体更加无感:
- 点击 Notion Button,触发 Webhook 发送文章 ID;
- Cloudflare Workers 接收到文章 ID,自动开始运行 Github Action Workflow;
- 全自动、零代码、低心智负担的自动部署流程。
但是 Webhook 是付费功能(笑),我自然是付不起每个月 12 美元的 Plus 订阅的,这个方案可能只能是一个方案了。





