我的博客技术栈

2022-05-01 ⏳15.1分钟(6.0千字)

很久之前就说要把博客的技术栈开源并撰文介绍,拖到今天终于下决心兑现承诺了。本站使用 markdown + syncthing + pandoc 搭建,跟主流博客系统相比最鲜明的特点就是帮助作者专注于内容创作,而不需要把大把的精力浪费到工具上。本文会详细说明我设计这套系统的来龙去脉及其使用效果。如果你有一定的 Linux 基础,可以轻松部署使用。本博客大量使用 bash 脚本,如果你想学习 bash 编程,那本文也能提供一些入门材料。好了,我们开始文章正文。

早期探索

我在大学时代就接触过博客,但一直断断续续,没有积累下有效的内容。那时候主要用 Wordpress,自己租一个支持 PHP 空间,整天部署来部署去,文章反而没写几篇。再后来开始使用 Github Page,也使用 jekyll, hugo 等来生成纯静态网页。这个阶段除了吭哧吭哧写 Git 提交之外就是折腾各种主题。一段时间下来也没写多少内容,最后直接弃坑了……后来事情有了转机,还得靠知乎。经过再三比较,我决定在知乎上创作。原因也很简单,当时我的知乎有已经几百个关注。陆陆续续写了四十篇文章。其中UTF-8往事是第一篇阅读量比较大的文章,这给我带来了不小的激励。但知乎的编辑器非常难用,写作体验很差。当我翻译 Emoji的奥秘的时候,知乎甚至连好多Emoji都支持不了。这又让我萌生了独立建站的想法。

在使用知乎的同时,我偶然发现了blot.im。这是博客托管平台。它最大的特点是跟 Dropbox 无缝集成。你在 Dropbox 上创建一个文件夹并跟 blot.im 绑定。然后你保存到 Dropbox 的文件就会自动发布到 blot.im 平台上。这个方案的美妙之处在于你可以使用任何本地工具进行创作,再也不用考虑 Web 编辑器的体验问题。而且可以随时随地创作和修改,写博客跟写本地文章没有任何区别。但可惜的国内没法使用 Dropbox 的同步功能。

我研究过其他同步盘服务。海外的 box.com,也在国内没法自动同步。国内的有坚果云,但不支持集成外部服务。于是我就有了一个大胆的想法,自己做一个类似 blot.im 的博客平台。

自动同步

做这样一个平台,首先要解决内容同步问题。我没有精力能力做一套文件同步工具,就想看看有没有现成的工具。还真有,这就是 Syncthing1。它是一款跨平台的点对点文件同步工具。只需要在服务器和本地电脑各装一套 Syncthing 就能自动同步文件。安装和配置 Syncthing 非常简单,就不作赘述了,具体可以参考官网。

解决了文件同步问题,接下来最核心的就是内容处理了。现在最流行的内容格式应该是 Markdown,所以很自然我们需要一个工具,来把 Markdown 转换成 HTML。我们在本地电脑以 Markdown 格式进行创作。创作完成后能过 Syncthing 将 .md 文件自动同步到服务器。服务器发现有文件变更后自动将 md 转成 HTML。

Markdown 转 HTML

md 转 HTML 的工具有很多,主流编程语言也提供了大量的工具库。我自己比较懒,不想写太多代码,于是选择了 Pandoc2。它支持各种文件类型的相互转换,Markdown 转 HTML 只是其中的一种。

Pandoc 工具

那如何将 Markdown 转成 HTML 呢?核心命令如下:

pandoc -s -p --from=gfm --template=article.tpl hello.md -o hello.html

这里的-s表示要输出完整的 html 文件。-p表示保留内容中的制表符。 --from=gfm表示使用 GitHub 风格 Markdown3 语法。

HTML 模版

--template指定输出 html 文件的模板。最简单的模板长这样:

<!doctype html>
<html lang="zh">
  <head>
    <title>$title$</title>
    <meta name="description" content="$description$">
  </head>
  <body>
  <h1>$title$</h1>
  <a rel="author" href="$author_url$">$author_name$</a>
  <date>$date$</date>
$body$
  ${ footer.tpl() }
  </body>
</html>

整个 HTML 结构非常简洁。<head>部分声明了标题和文章摘要。这里的$title$$description$ 是 Pandoc 提供的变量。它会在生成 HTML 的时候填充对应的值。<body>部分声明了标题<h1>、作者信息、创作时间和文章内容$body$。Markdown 文章内容会转换成 HTML 填充到$body$ 所在的位置。再后面是插入页脚模板。${ footer.tpl() }会将footer.tpl模版文件导入到当前模版,然后填充里面的变量。这跟 C 语言的#include很相似。

那么问题来了,这些变量的值从哪里来呢。变量有两类:

---
title: Blogging Like a Hacker
date: 2022-05-01
author_name: 涛叔
author_url: https://taoshu.in/about.html
---

这是 markdown 正文……

---之间的部分就是 Front Matter,这部分遵循 yaml 语法。我们可以在这里定义各种变量,然后在模板中引用。比如在上例中的$description$,最简单的办法就是在 Matter 手工整理 description 内空。但这样做太麻烦了。为了进一步降低创作门槛,我自己规定文章的第一段就是摘要。所以我需要 Pandoc 能自动提取文章首段并保存到$description$变量。要想实现这个效果,我们需要用到 lua 脚本。Pandoc 支持使用 lua 写插件。

提取摘要

我用的摘要提取插件如下:

local description = ""

function get_description(blocks)
  for _, block in ipairs(blocks) do
    --- 正文的第一段是一个 block,其类型为 Para
    if block.t == 'Para' then
      --- 把第一段的内容转成纯文本保存到全局变量中
      description = pandoc.utils.stringify(block)
      --- 跳过其他段落
      break
    end
  end
  return nil
end

return {{
  --- pandoc 以 block 为单位处理 markdown 文件
  --- 不同的 block 有不同的类型
  --- 我们指定 get_description 处理每一个 block
  Blocks = get_description,
  --- meta 信息在 block 之后处理
  --- 我们把之前保存的第一段内容保存到 description 中
  Meta = function (meta)
    meta.description = description
    return meta
  end
}}

核心逻辑我都加了注释,功能非常简陋,但 it works。如果想深入研究 Pandoc 的 lua 扩展,可以仔细研究官方文档[^lua-filters]。

有了 lua 脚本,我们还需要让 Pandoc 在处理 Markdown 文件时用上这个扩展:

pandoc -s -p --from=gfm --template=article.tpl \
  --lua-filter path/to/description.lua \
  hello.md -o hello.html

到这里,我们就能通过 Pandoc 将 Markdown 转换成对应的 HTML 文件了。但是,光有文章 HTML 不行,读者无法方便地的浏览博客的其他文章。为此,还得自动生成文章列表页面。这个过程就有点复杂了。

生成文章列表

排序与分页

一般的静态博客都会生成很多列表页面,觉见的有最近更新、每月发布、每个分类下的等等。这些列表页面往往还支持分页,实现起来非常麻烦。作为个人博客,我不可能写太多的文章。平均一周写一篇就已经很高产了。坚持周更的话,十年也才五百多篇,实际能写个两三百篇就算非常高产了。所以我判断不必给文章列表做分页,直接在列表页展示所有文章。这样一来也就不必再提供按月发布或者按类型发布的列表。综上所述,我决定只给博客生成一个 index.html,里面包含所有文章的链接、标题和发布时间信息。

提取 Front Matter

确定了列表的形式,马上就得设计排序规则。最简单的做法是按照文章的发布时间排序。但是 Unix 系统并不保存文章的创建时间,我们只能读取最近修改时间,显然无法直接依赖磁盘的文件系统。与是,我决定将发布时间写到 Front Matter 中的$date变量。精确到日期📅也就可以了,不需要写时间部分。但是,怎样才能根据文章的发布时间进行排序呢?这就需要在 Bash 脚本中提取 Markdown 的 Front Matter 信息,实现起来确实有点麻烦。

首先,我们需要找到所有的 md 文件:

mds=$(find $1 -name '*.md')

这里的$1表示 bash 脚本的第一个参数。给find指定-name '*.md'参数可以搜索对应目录下所有扩展名为 md 的文件。$(...)是 shell 的特殊语法,意思是执行括号中的命令,获取执行结果。最后我们把找到的 md 文件列表保存到$mds变量中。

注意,这等号两边一定不能加空格⚠️

Bash 还有一个反人类的地方就是变量在赋值的时候不能加$,但在引用的时候又必须加上!

正常来说,find 输出的列表是一行一行的。但使用$(...)捕获之后就会被转换成空格分割的字符串。初学者一定要注意。

为了方便用户指定变量,我们可以在脚本中加上这么一行:

source ./env

可以简单类比成 C 的#include语法。我们可以在env设置各种变量,供脚本的其他地方使用。

接下来,我们开始生成一个 YAML 文件。等等,为什么是 YAML 文件呢?我们前面说 Pandoc 中的变量有两种,其实不严谨,因为我们还可以通过--metadata-file=var.yaml参数来指定一个 YAML 文件,里面定义的变量跟 Markdown 文件中 Front Matter 定义的变量效果是一样的,Markdown 文件中的优先级更高。

为了生成文件列表,所以我们需要提取所有文件信息,然后排序,最后保存到一个 YAML 文件备用。

# 保存基础信息
echo "title: $site_title" > $1/index.yaml
echo "site_title: $site_title" >> $1/index.yaml
echo "site_url: $site_url" >> $1/index.yaml
echo "author_name: $author_name" >> $1/index.yaml
echo "author_email: $author_email" >> $1/index.yaml
echo "author_url: $author_url" >> $1/index.yaml
# 准备保存文章列表
echo "articles:" >> $1/index.yaml
# 生成文章列表
echo $mds | tr " " "\n" | xargs -I % meta.sh % | \
        sort -r |
        awk -F, '{print "- {\"path\":\""$2"\",\"title\":\""$3"\",\"date\":\""$1"\"}"}' >> $1/index.yaml

上面的代码又是各种超纲内容,容我一一分解。

首先echo "..."表示输出一段文本。一般文本内容会直接打印到命令行。但是,我们也可以把要 echo 的内容保存到文件。这就需要用到 io 重定向。echo "..." > $1/index.yaml 的意思就是把要显示的内容保存到 index.yaml 文件。而且这里既有>,又有>>>表示创建新文件,如果文件已经存在则清空内容;>>表示只把内容追加到已有的文章。如果文件不存在,>>>都会创建新文件。

再一个就是变量替换功能。echo "title: $site_title"实际输出的是title:加上变量 $site_title的值。Bash 默认支持变量替换,这跟其他语言不一样。

在分析最后的 Bash 代码之前,我们先插播一段 YAML 的语法。简单来说,YAML 是 JSON 的超集。合法的 JSON 也是合法的 YAML。所以 JSON 中的对象{"foo":1}在 YAML 中也表示对象。但 YAML 毕竟是超集,提供一些更方便的语法,比如列表可以用类似 Markdown 的列表语法来表示:

articles:
- {"path":"a.html", "title": "foo", "date": "2022-05-01"}
- {"path":"b.html", "title": "bar", "date": "2022-05-02"}

我们接下来要做的就是生成这样一个 YAML 列表。我把对应的 Bash 代码拆解如下:

echo $mds | \
  tr " " "\n" | \
  xargs -I % meta.sh % | \
  sort -r |
  awk -F, '{print "- {\"path\":\""$2"\",\"title\":\""$3"\",\"date\":\""$1"\"}"}' \
  >> $1/index.yaml

这里出现了管道操作符|。我们之前说echo可以把文本输出到终端,也可以保存到文件。除此之外,还可以把内容发送给其他命令,这里就要用到管道。管道就是所谓的匿名队列,用|表示。竖线左边的程序将内容写入队列,右边的程序从队列中读取,从而完成单向通信。更深入的内容可以参考我的译作解密 TTY 设备

tr命令全称是 translate characters,就是字符替换的意思。这里把空格改成换行。

再后面的xargs是 bash 的大杀器。简单来说是从标准输入(这里是管道)中逐行读取内容,然后作为参数传给 meta.sh 脚本。这里需要用-I %指定占位符。举个例子。如果 xargs 读到一行内容为 foo/bar.md,那么它就会对应执行meta.sh foo/bar.md。xargs 还有一个参数-P可以指定并发数。也就是说,xargs 一边从 stdin 读取内容,一边可以起动多个进程并行处理,这是 xargs 大杀器。但是,我们一个小博客,杀鸡就不用宰牛刀了。

xargs 配合执行 meta.sh 后的所有输出结果会通过管道传输给sort命令进行排序。 -r参数表示按 ascii 降序排列。最后是 unix 中的另一个大杀器 awk。

-F表示是分割符。awk 默认的分割符是所有空白字符。awk 本身就是一种脚本解释器,专门用于处理文本,最典型的使用场景就是汇总各类数据,多用于日志分析等场景。它每次读取一行内容,然后分割成小块,每一块可以用$加数字引用。awk 的脚本使用大括号包裹, print 表示输出内容,其语法跟 echo 有点像。

这里的 awk 代码如下:

{print "- {\"path\":\""$2"\",\"title\":\""$3"\",\"date\":\""$1"\"}"}

因为 print 的参数用了双引号,所以需要输出双引号的地方都得加上反斜杠转义。上面的代码意思也很明显,输入的内容以逗号分割,第一部分分是日期,第二部分是路径,第三部分是标题。然后用 awk 拼接成 YAML 的列表对象语法,并将内容追加到 YAML 文件。

awk 非常复杂,这里只用了它一点皮毛。如果想进一步学习,可以阅读我的另一篇文章20分钟降服awk

现在我们看一下 meta.sh 的代码:

meta=$(sed -n -e '0,/^$/p' $1)

file=$(echo $1|sed -E 's/^\.//'|sed -E 's/\.md$/.html/')
title=$(echo "$meta"|grep title|sed -E 's/\w+:\s+//'|sed 's/"//g')
date=$(echo "$meta"|grep date|sed -E 's/\w+:\s+//')

echo "$date,$file,$title"

这里面用到了 unix 的另一个大杀器 sed。sed 主要用于流式文本处理,支持正则表达式。这里的流式的意思是就一边读取一边处理一边输出。

meta.sh 的功能就是从 Markdown 的 Front Matter 中提取日期、路径和标题。在 Bash 中处理 YAML 本身比较麻烦,所以我采用了一取巧的办法。Front Matter 最大的特征是开头和结尾都是---。但可惜我没想到可以利用这个特性的办法。我还注意到一般 Front Matter 跟正文之间有一个空行。所以我们可以利用 sed 把从开头到第一个空间之前的部分提取出来。这就的语法就是0,/^$/p0表示内容的开头,目前只有 GNU 版本的 sed 支持。逗号后面的/^$/匹配空行。了解正则的读者应该知道^$分别匹配一行的开始和结果,两者之前没有内容就表示空行。如果想进一步学习正则可以参考我的文章。最后面的p表示输出匹配结果。

所以第一行连起来表示提取 mardkown 文件开头到到第一个空行为止的内容,保存到 meta 变量中。我这个简单方案要求 Front Matter 中不能有空行。

那能不能使用---当作结束标志呢?不能。因为 markdown 一开始就是---。如果读者有更好的处理办法请务必留言赐教。

今天(2022-05-02)收到唐宋元明清的来信:

取meta信息那块正好之前有过类似经验,可以用awk配合flag变量拿到。大致操作是把.md内容管道给awk, BEGIN {flag=0} /—/ {flag=!flag; if flag print $0} 抱歉是手机打字,没有直接给bash命令而且也没验证,希望这点片段能表达清楚。

我测试了一下,awk 方案确实更加直观,也更灵活。完整代码如下(可以换行😄):

/^---$/{ n++ }
{
  if(n==1&&$0!="---"){print $0}
  if(n==2){exit}
}

awk 代码块之前都可以加//表示匹配规则。awk 会按行读取内容,只有匹配规则就会执行对应的代码块。上面的代码每当遇到---就会让变量n加一。awk 是一种动态脚本,变量不用定义就可以使用。后面那一段因为没有对应的匹配规则,所以对所有内容都生效。当n等于一的时候说明已经出现过一个---,接下来的内容是 Front Matter,需要通过 print $0输出。当n等于二的时候说明元信息部分结束,此时执行exit就让 awk 退出,而不再处理后续文章内容。

这种方案还有个好处是比较通用。只要是想匹配成对出现的分割符内容,都可以使用这个思路。比如我们想提取文章的第一段作为摘要,则只需要把上面代码中的^---$换成^$即可。

拿到了 Matter 信息就好办了。sed -E 's/\w+:\s+//'表示把冒号之前的部分替换成空字符。所以date: 2022-05-01就变成了2022-05-01。其他替换就不一一重复。最后把对应的变量按顺序输出。这里要求各变量中不能有逗号。

上述使用 awk & sed 提取 Front Matter 变量确实可歌可泣。不但实现复杂,而且兼容性很差,对 Front Matter 的变量值有很多假定。后来我直接用 pandoc 来提取了。

思路是提供一个 meta.tpl 模版:

- { "date": "$date$", "path": "$path$", "title": "$title$", "desc": "$desc$", "updated": "$updated$" }

然后用 pandoc 渲染生成 meta.yml 文件,pandoc 会自动填充对应的变量。

细节可以参与我的别一篇文章(英文)。

为什么要把发布时间放在第一位呢?还记得我们在上文提到的sort -r吗。因为第一部分是日期,所以按降序排列之后最新发表就会出现在文章列表的最上面了。不过这里要求日期要补零,也就是五月要写成05

到这里,我们就生成了所需的 YAML 文件。整个文件长这样:

title: 涛叔
site_title: 涛叔
site_url: https://taoshu.in
author_name: 涛叔
author_email: hi@taoshu.in
author_url: /about.html
articles:
- {"path":"/lets-web-feed.html","title":"Web Feed 倡议书","date":"2022-04-20"}
- {"path":"/web-feed.html","title":"Web Feed 简介","date":"2022-04-16"}
- {"path":"/free-economist.html","title":"如何免费阅读经济学人文章","date":"2022-04-15"}

有了 index.yaml 文件,再配合专门的文件列表模板,我们就能生成需要列表页面:

pandoc -s -p -f markdown \
  --template index.tpl \
  --metadata-file=$1/index.yaml \
  -o $1/index.html /dev/null

这里比较特别的一点是没有对应的 Markdown 文件,所以使用/dev/null代替。为什么呢?因为这个列表本来就是动态生成的,就是没有对应的 Markdown 文件。但是如果不给 Pandoc 指定源文件路径,它会报一个警告,所以就用了/dev/null

列表模版

文章列表的模版内容长这样:

<!doctype html>
<html lang="zh">
  <head>
    <title>$site_title$</title>
  </head>
  <body>
    <header class="index">
     <h1><a href="/">$site_title$</a></h1>
    </header>
    <ol id="articles" reversed>
    $for(articles)$
      <li><a href="$it.path$">$it.title$</a> <date>$it.date$</date></li>
    $endfor$
    </ol>
  </body>
</html>

依然是极简风。大家注意列表的生成方法。这里用到了 Pandoc 提供的$for$...$endfor$语法。遍历articles变量(来自 index.yaml 文件),然后通过$it$引用当前的循环变量,生成对应的连接信息。

我还给有序列表<ol>开启了reversed开关,这样列表会倒序编号。读者一进来发现我的博客有上百篇文章,肯定会被唬住😆

为了进一步方便读者检索,我还会给每一个文件夹生成文件列表,只用了一行代码:

find . -type d ! -exec index.sh {} \;

增量构建

考虑到只有 markdown 文件发生变更才需要重新生成对应的 html 文件。是不是有点类似 C 语言的编译。所以我们很自然地想到用 make 来实现增量转换。make 文件语法又是新的知识点。

# 使用 find 命令查找所有 md 文件,并保存到 MDs 列表
MDs := $(shell find . -name '*.md')
# 将 .md 统一替换成 .html 保存
HTMLs := $(MDs:.md=.html)
# 确定 Makefile 所在的目录,我们需要引用同目录下的其他文件
ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))

# 自动化的 make 规则
# 生成 .html 文件依赖对应的 .md 文件和其他模板文件
# 只有依赖发生变化才需要重新执行对应的 pandoc 命令
# 这里的 % 表示通配符
%.html: %.md ./head.tpl ./footer.tpl ./article.tpl
        pandoc -s -p --from gfm --highlight-style=pygments \
                --template article.tpl \
                --metadata-file=index.yaml \
                --lua-filter $(ROOT_DIR)/description.lua \
                $< -o $@

# 生成文件列表规则,同样依赖对应的模板
index: ./head.tpl ./footer.tpl ./index.tpl
        find . -type d ! -path '*.assets' -exec index.sh {} \;

# 总构建目标
all: index $(HTMLs)
        feed.sh . > feed.xml

all目标依赖index目标各所有.html目标。这里用到了$(HTMLs)变量,make 会自动展开。对于每一个 html 目标,都会匹配%.html目标。

所以当你切换到博客根目录执行make -f path/to/Makefile的时候,make 会找出所有 .md 文件的修改时间比 .html 文件的修改时间还要新的文件列表,然后批量生成对应的 .html 文件,最后再给每一个目录生成对应的文章列表。

如果博客中的文件夹比较多的话,生成文章列表会消耗比较长的时间。

好了,到现在我止,我们实现了文章和列表 html 的增量构建。最后一步就是如何实现自动化构建。

自动化构建

自动化的思路也非常简单,就是监听文件的变化。如果有变化就自动执行 make。反正已经是增量构建了,重复构建消耗资源有限。所以最开始我使用 inotifywait 来监听。但后来想着要把这套工具做成平台提供给有需要的朋友,需要跟 Syncthing 集成,所以就迁移到了 Syncthing 的 Event 接口5

Event 接口简单来说就是一个长轮询的 HTTP 接口。我写了一个 Bash 脚本不停地检查是否有文件变更事件,如果有就触发对应的 make 构建。

trap exit SIGINT SIGTERM

since=0
events=RemoteChangeDetected,PendingDevicesChanged

# 首次启动跳过已经发生的事件
curl -s -H x-api-key:$api_key "$host/rest/events?timeout=0&events=$events" > /tmp/events.txt
last=$(jq -r '.[]|.id' /tmp/events.txt | tail -n 1)
if [[ ! -z "$last" ]]; then
  since=$last
fi

while true; do
  curl -s -H x-api-key:$api_key "$host/rest/events?since=$since&events=$events" > /tmp/events.txt

  # 如果删除 md 则同步删除对应的 html
  jq -r '.[]|select(.type == "RemoteChangeDetected")|select(.data.action == "deleted")|"\(.data.folder)/\(.data.path)"' \
    /tmp/events.txt | sort | uniq | grep -E "\.md$" | sed -E "s/\.md$/.html/" | \
    xargs -I % rm -f ~/sync/%

  # 目前无法区分新建文件和修改文件,只能无脑触发更新索引页面
  jq -r '.[]|select(.type == "RemoteChangeDetected")|.data.folder' \
    /tmp/events.txt | sort | uniq | \
    xargs -I % build.sh %
	
  # 更新事件id
  last=$(jq -r '.[]|.id' /tmp/events.txt | tail -n 1)
  if [[ ! -z "$last" ]]; then
    since=$last
  fi
done

以上就是博客的主要实现思路。构建好的博客是纯静态网站,可以使用任意 HTTP 服务器对外发布。我还自己写了一个简单的留言系统,有兴趣的可以参考我的文章。 Atom feed 的生成过程跟文章列表页构建非常类似,这里就不重复了。

总结

我在开头说过,这套博客系统有别人所不具备的优势,那就是完全不侵入创作过程。因为有了它,我可以随时随地进行创作和修改,甚至都意识不到它的存在。我在2021年一共写了50篇文章,从侧面也印证了这套系统的效率。我把它做成了一个 SAAS 平台,取名为乐乎,有兴趣的读者可以试用。我自己的博客本身也是由乐乎驱动。要说这套系的缺点,我认为最突出的就是初次配置比较繁琐,它要求用作者要购买域名、配置 Syncthing、学习一点点 Pandoc 模板语法(非必须)。一旦完成配置,基本上就感觉不到它的存在。我认为这是工具该有的样子。

lua-filter 相关博文


  1. https://syncthing.net↩︎

  2. https://pandoc.org↩︎

  3. https://github.github.com/gfm/↩︎

  4. https://jekyllrb.com/docs/front-matter/↩︎

  5. https://docs.syncthing.net/dev/events.html↩︎