尝试了一下 Lektor 这个静态内容管理系统,觉得挺好用的,于是把博客从 WordPress 迁移到了 Lektor。

一、动态博客与静态博客

本博客的历史可以追溯到 2009 年,至今已经 8 年,曾使用过 Blogger 和 WordPress 作为博客软件。头几年写博客热情高涨,比较高产,而近几年由于种种原因,已经很少写博客了,最近两年基本是一年一更的节奏。博客本身也疏于打理,偶尔想起来,登录一次 WordPress,等待我的却是满满一筒的垃圾评论(被反垃圾插件过滤的),偶尔还能从垃圾筒里捞出几个被误杀的非垃圾评论。除此之外的例行便是点一下 WordPress 里的升级按钮,把 WordPress 本身和各种插件、主题全部更新成最新版本。

前段时间无意中发现了 Lektor 项目。阅读了一下作者的自述,产生了不少共鸣:

The longer I'm programming and creating software, the more I notice that I build a lot of stuff that requires maintenance even though it should not. In particular a topic that just keeps annoying me is how quickly technology moves forward and how much effort it is to maintain older code that still exists but now stands on ancient foundations.

This is not a new discovery mind you. This blog you're reading started out as a Django application many, many years ago; made a transition to WordPress because I could not be bothered with updating Django; and then turned into two different static site generators because I did not want to bother with making database updates and rather wanted to track my content in a git repository.

写得不多,却要隔段时间维护一次——这正是我使用 WordPress 写博客的感受。当然,我并不说 WordPress 不好——WordPress 本身是一个很成熟而强大的内容管理系统,但是对我的使用场景来说,也许一个静态博客更加适合我。

我曾听说过 Jekyll, Huxo, Hugo 等静态网站生成器,但是从来没有尝试过。而 Lektor 正巧在我产生「我想试试静态博客」这样的念头的时候撞了上来,并且它使用的 Flask 和 Jinja2 这两大项目都是我使用较多、比较熟悉的(这三个项目都是同一作者 Armin Ronacher),应该会上手比较快吧?抱着这样的念头,我花了几天时间,把 WordPress 成功迁移到了 Lektor。这篇博客便是在 Lektor 里写的第一篇博客。

二、Lektor Quickstart

在正式使用一个项目之前我喜欢把它的文档完整读一遍(而不是只看「Quickstart」部分),Lektor 的文档并不长,我很快就读完了,以下是我总结的「Quickstart」。

安装与初始化

作为一个 Python 项目,Lektor 的安装无非就是 python -m venv venv --prompt lektorpip install lektor。静态博客肯定会用到版本管理,所以初始化一个 Git repo 存放 venv 和 Lektor 工程文件是比较自然的事情。Lektor 提供了 lektor quickstart 可以交互式地创建一个空的工程目录,里面有一个示例的站点,可以用 lektor server 起一个 dev server 然后在浏览器中访问查看。

lektor server 做了两件事情,一是把工程文件构建成 .html 文件输出到 $HOME/.cache/lektor/ 目录下,二是起一个 HTTP Server 服务这个目录。另外,Lektor 还会监视工程文件的变动,如有变化会自动重新构建。需要注意的是,在 dev server 的情况下,Lektor 会在页面里插入一段 JavaScript,在每个页面右上角显示一个悬浮的编辑按钮,用于在它的 /admin 页面编辑本页,实际上磁盘里已经输出的文件里并没有这样的 JavaScript 和按钮。实际写博客的时候,不一定要用它的编辑后台,可以直接用编辑器打开工程文件,在里面写。

目录结构

一个 Lektor 项目的工程文件是这样存放的:

.
├── assets
│   ├── favicon.ico
│   ├── robots.txt
│   └── static/
├── content/
│   ├── 1/
│   │   └── contents.lr
│   ├── 2/
│   │   └── contents.lr
│   ├── 3/
│   │   └── contents.lr
│   └── contents.lr
├── models/
│   ├── blog.ini
│   └── blog-post.ini
├── templates/
│   ├── blog.html
│   ├── blog-post.html
│   ├── layout.html
│   └── macros/
└── wzyboy.im.lektorproject

几个目录的定义:

  • 以 .lektorproject 结尾的文件是项目的配置文件(文件名不重要);
  • content 目录是实际存放内容的目录,可以分层,但每层必须要有一个 contents.lr 文件,哪层没有,就不再向深处搜索;
  • models 目录定义了 contents.lr 的数据结构,即里面可以有哪些字段,每个字段的数据类型等,以下划线开头的字段是系统保留字段;
  • contents.lr 是纯文本文件,使用 Key: Value 存储字段,用单独一行三个连字符(---)作为各字段的分隔线,Value 可以是多行的;
  • templates 目录存放 HTML 模板,用 Jinja2 写成,从 contents.lr 里解析出来的数据被填进这里,就渲染出了最终的页面;
  • assets 目录里的东西会保持整体结构叠加/覆盖到最终的输出目录里,可以用来放 CSS 和 robots.txt 之类的;

数据结构

直接以我定义的两个数据结构文件 blog.ini 和 blog-post.ini 为例。由于我不使用它的 /admin UI,所以与 UI 相关的 label 等属性我已经略去,以下为最简数据结构:

# blog.ini
[model]
name = Blog

[children]
model = blog-post
slug_format = /post/{{ this._id }}.html
order_by = -pub_date

[pagination]
enabled = yes
per_page = 5

blog.ini 里的数据结构用于博客的首页,它的子目录将会使用 blog-post.ini 里的数据结构,并且以 pub_date 字段降序排列(由新到旧),一次返回五个条目。如果翻译成 SQL 的话,大致是这样:

SELECT * from blog_post ORDER BY pub_date DESC LIMIT 5;

# blog-post.ini
[model]
name = Blog Post

[fields.title]
type = string

[fields.author]
type = string

[fields.pub_date]
type = datetime

[fields.body]
type = markdown

[fields.excerpt]
type = markdown

blog-post.ini 定义了单篇博客的数据结构,声明每个字段是什么数据类型。完整的数据类型定义在这里

下面以我从 WordPress 迁移到 Lektor 的实际过程为例,展示这些数据结构的使用。

三、从 WordPress 迁移

兼容 URL 样式

从 WordPress 导出成 Lektor 的 contents.lr 文件可以用 lektor-exportor 这个 PHP 项目完成。导出的内容为每篇文章一个目录,每个目录里一个 contents.lr。目录名默认是文章标题。因为我的文章标题大部分含有中文字符,实际效果是一堆百分号转义符……为了美观,也为了方便和原来 WordPress 的 URL 结构(/post/<id>.html)保持一致,还是重命名一下好了。在 contents.lr 里有 _slug 字段保留了原来的 URL,把里面的 Post ID 提取出来作为目录的名字:

$ for i in *; do id=$(grep -Po '(?<=^_slug: /post/)\d+(?=\.html)' "$i"/contents.lr); echo $id; mv -v "$i" $id; done

重命名完成后,把这些以 Post ID 作为名字的目录复制到项目的 content/ 目录下,再创建一个 content/contents.lr 文件,里面只有一行 _model: blog。这样,就把博客首页及每篇博客(即 content/ 目录下的带 contents.lr 子目录)的数据结构声明完成了。

另外,在 blog.ini 里用 slug_format 规定了子条目的 URL 样式为 /post/{{ this._id }}.html,而 _id 这个系统字段即是目录名,这样最终的每篇文章的 URL 就和原来 WordPress 的一致了。之后的新文章不再需要 contents.lr 里指定 _slug 了,现有的文章的 _slug 字段也可以删除,而使用 blog.ini 里已经指定好的 URL 样式。

迁移 WordPress 附件

WordPress 中上传的附件(主要是图片)是按年月分目录存储的,比如 /wp-content/uploads/2012/01/foo.png 这样。Lektor 虽然每篇文章能添加附件(默认情况下放在文章目录里文件都会被当成附件),但是要把 WordPress 里已有的这些附件转换成 Lektor 的附件还是比较麻烦的,还不如直接丢 assets/ 目录里。然后我灵机一动,干脆直接传到 AWS S3 得了。于是把整个 /wp-content/uploads/ 目录用 rclone 上传到 S3,再用 sed 对现有文章做一次批量替换,就成功地完成了现有 WordPress 附件的迁移。后来发现那些转载我的文章的网站里,图片的外链都挂了。如果想要对旧图片的 URL 进行兼容的话,可以考虑在 Nginx 中增加跳转:

location ~ /wordpress/wp-content/uploads/(.*) {
  return 301 https://s3.amazonaws.com/wzyboy-wordpress-uploads/$1;
}

由于我的博客前面有 CloudFront,所以我也可以直接用 CloudFront 对原来 WordPress 的附件路径做反代,将请求转发到 S3 bucket 里。

至于以后的附件么,我写了个简单的 Python 脚本,上传图片到 S3 然后直接打印一个 <img> 标签。这样就可以方便地在文章里贴图了。甚至「目录」结构也是仿照 WordPress 那样按年月分类的。

WordPress 里上传图片之后会自动生成多个尺寸的缩略图。既然已经用 S3 作为图床了,那可以考虑建立一个 AWS Lambda,监听 S3 上传事件,检测到有新图片上传就自动生成缩略图传到同样的「目录」下。这种 AWS Lambda 的用法甚至是 AWS 官方教程的一部分。

在迁移附件过程中,我发现我最早有一批附件并没有上传到 WordPress,而是上传到 Flickr, Picasa (!) 等图床,这些外链图片早就已经年久失修崩坏了,好在它们中的大部分还是能看出文件名的,而我所有的截图都是有存档的,于是我从备份中捞出了它们,用上面所说的脚本重新上传,顺便修好了这些外链。

生成文章摘要

由 WordPress 导出的数据里,每篇文章有个 excerpt 字段,存储了文章的摘要(默认是文章的前若干个字)。摘要可以在渲染博客首页的时候用到。Lektor 里并没有自动生成摘要的功能,但是 Lektor 竟然自带了一套神奇的插件开发系统,使用 lektor dev new-plugin 即可交互式地建立一个插件的框架(自动放在工程目录的 packages/ 子目录里),里面已经有了 .gitignore, setup.py 和一个 Python 文件示例。它的插件系统非常好懂,就是在各种 event 上面挂钩子。那么在 setup-env 事件的时候往 Jinja2 Env 里注册一个生成摘要的 filter 即可。我写了一个简单的示例,把文章内容的所有 HTML 标签扒掉,留下纯文本,然后截取前若干个字符返回。稍加改动可以实现更加复杂的摘要输出算法。

生成 Sitemap 与 RSS / Atom

WordPress 里有插件可以生成 sitemap.xml。但这 .xml 也不过就是个 markup language 文本文件而已,所以用 Jinja2 渲染一个就行了。参考官方文档

至于 RSS / Atom,也是个 .xml 文件,理论上也可以用类似的方法渲染出来,不过考虑到 Google Reader 倒闭之后没多少人继续用 RSS 了,所以我暂时没去研究它的生成。(其实是我懒)这儿有个现成的 lektor-atom 插件可以用于生成 feed.xml 文件。比如本博客的订阅链接现在就是 https://wzyboy.im/feed.xml。需要注意的是,由于没有动态服务器会往这个链接的返回里添加 Content-Type: 了,所以最好添加一个 .xml 扩展名,让 HTTP Server 自动添加一个合适的 Content-Type:

此外,可以在 Nginx 中添加重定向以兼容已有的 WordPress 订阅者:

location = /feed {
  return 301 /feed.xml;
}

location = /feed/atom {
  return 301 /feed.xml;
}

评论

静态网站没法实现评论系统,只能外包。可以考虑 Disqus 之类的。

更新:本博客已增加(可选的)Disqus 评论功能。你可以点击每篇文章末尾的按钮,加载 Disqus 评论。在你点击按钮前,Disqus 不会被加载。有关 Disqus 评论与读者来信的隐私声明,请阅读《隐私声明》。

主题

这个其实是最花时间的。WordPress 的主题多如牛毛,而 Lektor 的主题……几乎没有。好在我之前 WordPress 主题是一款基于 Bootstrap 的主题。我对 Jinja2 还算熟悉,对 HTML 和 CSS 也有一定的了解,于是对着 Bootstrap 的文档,很快就把 WordPress 的主题基本复制过来了。但是剩下的细节,比如文章里各种图片的 inline style 等,还是不够完美,触发了我的强迫症。于是我 sed + Perl + Beautifulsoup 各种工具齐上阵,终于把现有文章里各种图片的样式调对了,至少所有的页面都实现了响应式布局。

部署

如前文所说,通过 lektor server 在浏览器看到的页面几乎就是最终 HTML 了,只不过额外注入了一个 JavaScript。要真正部署的话,需要用一个全功能的 HTTP Server,如 Nginx,把最终的 HTML 文件服务起来,而不是用 Lektor 自带的简易 server。Lektor 自带了 deploy 命令,可以用 rsync 或 FTP 将 HTML 文件发布到别的机器上(通过第三方插件也可以支持发布到 S3 上)。但是我希望能看到两次发布之间到底有哪些修改,因此我选择把最终的 HTML 文件也纳入 Git 版本管理。使用 lektor build -O dist 即可最终文件输出到 dist/ 目录里,而不是默认的 $HOME/.cache/lektor/ 目录。将 HTTP Server 的 web root 指向该目录,即完成了部署。再也不需要调配 MySQL 和 PHP 了。

四、Lektor 最佳实践

目前就想到这两个。再有想到的再补充吧。

markdown 与 html

在定义 blog 数据结构的时候,body 和 excerpt 字段都是 markdown 数据类型(而不是 html 数据类型),但其实这些字段都是可以放 HTML 的。事实上 WordPress 导出的 contents.lr 里 body 字段都是 HTML。这个字段的内容是可以 Markdown 与 HTML 混排的。在写博客的时候,大部分元素 Markdown 比 HTML 方便,因为不用写一堆 <p><h1> - <h6>,但是面对一些稍复杂的元素,比如引用、代码块、表格等,Markdown 需要对 * _ 等代码常用字符进行转义,实在是不方便。而 Markdown 与 HTML 则解决了这个问题。本篇文章在写作的时候就是 Markdown 与 HTML 混排的,<p> <h1> - <h6> 是用 Markdown 写的,而 <blockquote> <code> <pre> <img> 等则用 HTML 写成。Lektor 在把 Markdown 字段渲染成 HTML 的时候,遇到成对的 HTML 标签会跳过,原样保留。这样的特性我很喜欢。

回收站

在 WordPress 里可以设置一篇文章的隐藏属性,或将一篇文章移入回收站。Lektor 里,有 _discoverable 和 _hidden 两个系统字段,如果前者为假,则 HTML 依然会生成,但是在文章列表中不会显示,只有知道 URL 才能访问到;如果后者为真,则 Lektor 会完全跳过这个文件不再生成 HTML,此时这个文件只有被 explicitly include 才会出现在输出的 HTML 里。如果有些文章不想公开却又舍不得删,可以调整这两个字段的真假值,达到类似于回收站的效果。如果建立一个 trash 目录,再在这个目录的 contents.lr 里指定这两个值,那么所有移入这个目录的文章都进了回收站。

五、后记

友人听说我从 WordPress 迁移到了 Lektor,很惊讶我竟然这么晚才尝试静态博客。的确,这不是什么最近才流行起来的东西。在我将博客从 WordPress 迁移到 Lektor 的过程中,我真切地感受到了静态博客最大的优势:所有的内容都是朴实、平白、易于操作的文本,可以用各种文本工具方便地批量修改,可以用版本控制工具比较与回退。就像纯文本记账一样美好。


欢迎留下评论。评论前,请先阅读《隐私声明》。