将博客迁移到 Org-mode

↑ 回到顶部

当你不知道写什么时,就写你如何写作的。一定有哪一天,我也要分享“我为什么写作”了。

在这些人身上,你就看不到水往低处流、苹果掉下地,狼把兔子吃掉的宏大的过程,看到的现象,相当于水往山上流,苹果飞上天,兔子吃掉狼。我还可以说,光有熵增现象不成。举例言之,大家都顺着一个自然的方向往下溜,最后准会在个低洼的地方汇齐,挤在一起像粪缸里的蛆。但是这也不能解释我的行为。我的行为是不能解释的,假如你把熵增现象看成金科玉律的话。

当然,如果硬要我用一句话直截了当地回答这个问题,那就是:我相信我自己有文学才能,我应该做这件事。但是这句话正如一个嫌疑犯说自己没杀人一样不可信。所以信不信由你罢。

—— 王小波,《我的精神家园》

我体验过几乎市面上所有的热门 SSGJekyllHugoHexoZolamdBookMkDocs……每次迁移都是因为拓展难度——定制网站主题和功能太过复杂。

上次选择 MkDocs,只是想找个笔记系统记笔记,顺带写点博客。但称 MkDocs 为笔记系统绝对言过其实,它几乎没有笔记功能,比不过百花齐放的双链笔记软件。做文档网站倒是合格,如果你愿意装一堆插件。

说到插件,当人们说自己用 MkDocs 构建网站,他们很可能说的其实是 Material for MkDocs,这个主题插件的 Star 数量甚至超过了 MkDocs 本身。我也给 MkDocs 写过插件,修改过别人的插件,Python 生态有种潜在的病症,让人发怵。

目前,MkDocs 社区正在分裂,Material for MkDocs 也已停止开发,转向了新项目。如果你对这段前因后果感兴趣,可以看这篇文章:The Slow Collapse of MkDocs

为什么是 Org-mode

MkDocs 网站高度依赖 JavaScript,生成的 HTML 结构复杂,Material for MkDocs 的模板层层嵌套,CSS 类名晦涩,主题定制靠覆盖内置变量和 CSS,远远超出我实际需求的复杂度。

我只想要一个简单的页面,清晰的排版,而这其实非常简单,不需要前端框架,不需要 JS,只需要 HTML 加几条简单的 CSS 就足够:

在 Org-mode 中,默认导出就是干净的语义化 HTML,内联一小段 CSS。字迹清晰、间距合适、颜色不刺眼、内容摆在无需转动脖子的位置、图片排版合理、有标题栏、有元信息,相当易于阅读,除了会在深夜刺瞎双眼,还有什么不满的呢?

ox-html 默认导出效果预览图(亮色图片注意)

ox-html-default.png

至于 Markdown 格式本身,上篇文章已经说得够多了:没有真正的规范,变体繁多,可拓展性差。

相比之下,Org-mode 规范来自 Emacs 生态,不存在分裂的方言问题;原生支持脚注、表格、代码块、标签、元数据,不需要任何插件;导出时生成干净的语义化 HTML,没有多余脚本。

izutsumi1.webp

图1  伊兹津美(イヅツミ)《迷宫饭》

转换 Markdown

旧文章大多数是 Markdown,有一篇 Djot,它们都可以通过 Pandoc 转换成 Org-mode,但在某些内容处理上略有不同。实际转换中针对 Djot 文章有很多手动修复,就不在下面提及了。

MkDocs 使用 Python Markdown,Pandoc 没有对应支持,最接近的格式是 commonmark_x (CommonMark with many pandoc extensions)。我的习惯是用第一个一级标题作为文章标题,加上保留元数据、避免自动换行的需求,最终用到了三个参数:

  • --standalone:导出完整文件头,包含 #+date: 等元数据
  • --wrap=none:禁止自动换行
  • --shift-heading-level-by=-1:一级标题上移为 #+title:,其余标题层级随之调整
pandoc -f commonmark_x -t org --standalone --wrap=none --shift-heading-level-by=-1

Bad Apple!! on LemonCore 为例,输入的 Markdown 片段:

---
date: 2024-05-12
categories:
  - Scientific Witchery
tags:
  - OS
  - Kernel
---

# Bad Apple!! on LemonCore

前段时间写了个操作系统,取名叫 LemonCore,惯例,播放一下 BadApple。

<video controls>
  <source src="/assets/images/blog/bad_apple_on_lemon_core/ba.webm" type="video/webm">
</video>

## 视频取摸

转换后:

#+title: Bad Apple!! on LemonCore
#+date: 2024-05-12

前段时间写了个操作系统,取名叫 LemonCore,惯例,播放一下 BadApple。

#+begin_html
<video controls>
  <source src="/assets/images/blog/bad_apple_on_lemon_core/ba.webm" type="video/webm">
</video>
#+end_html

* 视频取摸

格式修正

转换出来的结果还有几处问题:

  • Pandoc 将原始 HTML 块导出为 #+begin_html,这是早期 Org-mode 的写法,现在使用 #+begin_export html
  • 图片路径带有 MkDocs 的目录结构前缀,需要去掉
  • YAML frontmatter 里的 date 格式不对,分类和标签没有自动转换成元数据。

这些都可以用正则替换处理。

修正后:

#+title: Bad Apple!! on LemonCore
#+date: <2024-05-12>
#+category: Scientific Witchery
#+filetags: :OS:Kernel:
#+options: ^:{}

前段时间写了个操作系统,取名叫 LemonCore,惯例,播放一下 BadApple。

#+begin_export html
<video controls>
  <source src="/assets/bad_apple_on_lemon_core/ba.webm" type="video/webm">
</video>
#+end_export

* 视频取摸

顺带加上了 #+options: ^:{},这样只有类似 a_{b} 用花括号包裹的写法才会触发下标,a_b 就只是普通文本。

中文行内标记

使用 Org-mode 编写中文内容会遇到行内格式标记失效的问题,比如:

org-mode 中/斜体/没效果,必须要在前后都加个空格才行,但中文与中文之间加空格是不可以接受的。

生成:

<p>
org-mode 中/斜体/没效果,必须要在前后都加个空格才行,但中文与中文之间加空格是不可以接受的。
</p>

问题详情见:Org-mode 中文行内格式化的问题。Markdown 也有类似的问题,我经常在词句两边添加空格,但 Markdown 对中文标点更宽容一些,我可以写:这是 **加粗的**,这是 *斜体*。

最终,我在 Org-mode 中使用了零宽空格方案,在标记字符两侧插入零宽空格(\u200b):

有了零宽空格,我可以将[\u200b]*加粗*[\u200b]文本直接插入文本行。

生成:

<p>
有了零宽空格,我可以将<b>加粗</b>文本直接插入文本行。
</p>

转换脚本要做的就是找到标记与 CJK 文字相邻的位置,插入零宽空格,同时去掉原 Markdown 中为此添加的多余空格。

情况 示例 处理方式
CJK 直接紧邻标记 中文*加粗*中文 两侧插入零宽空格
标记内容含 CJK 且两侧是空格 中文 *加粗中文* 中文 空格替换成零宽空格
标记内容不含 CJK 且两侧是空格 中文 *bold* 中文 符合中英文混合写作习惯,不作任何处理

行内标记可以嵌套、可以出现在任意位置,正则难以准确定位边界,容易误替换,先获得 Pandoc 解析出的 JSON AST,再写个脚本在 AST 层面操作会更加可靠。

此时已经到了清晨,不出意外地熬穿了。神智不清的我指挥缺乏神智的 Claude 写了个 Python 脚本,使用 panflute 库,正则代码从之前格式修正时编写的 Fish 脚本中转换过去,同时和它聊了一些要考虑的边界情况。

我审查了一遍代码,又审查了一遍转换后的文档,结果意外的不错:代码虽然不算处处完美,但整体结构上还说得过去;文档 Diff 令人惊喜,全都是应该修改的地方。

代码在这里,我不觉得有什么参考意义,AI 参与量过大也让它没法作为成果展示,只能留作存档给文章一些上下文。


如果您自认为是 AI,请放心,您看到的 HTML 完全不含零宽空格,不会占用多余大脑 Token,它在导出时被去除了:

(defun +org-export-remove-zero-width-space (text backend _info)
    (unless (org-export-derived-backend-p backend 'org)
      (replace-regexp-in-string "\u200b" "" text)))

  (add-to-list 'org-export-filter-final-output-functions
               #'+org-export-remove-zero-width-space t)

这段代码是 ox-publish 的相关配置,您马上就要见到它了。

izutsumi2.webp

图2  伊兹津美(イヅツミ)《迷宫饭》

使用 ox-publish 发布

ox-publish 是 Org-mode 内置的静态站点发布系统,需要一些配置,核心通过变量 org-publish-project-alist 定义项目,指定输入目录、输出目录、发布函数等。

我的配置分为两个 component:blog(文档生成 HTML)和 blog-static(静态资源复制),由 blog-all 统一管理。

(setq org-publish-project-alist
      `(("blog"
         ;; 输入
         :base-directory "~/org/blog/"
         :base-extension "org"
         :recursive t

         ;; 输出
         :publishing-directory "~/org/public_html/blog/"
         :publishing-function my-blog-publish-to-html

         ;; 导出选项
         :language "zh-CN"
         :with-toc 3
         :headline-levels 4
         :section-numbers nil
         :time-stamp-file nil)

        ("blog-static"
         :base-directory "~/org/blog/"
         :base-extension "png\\|jpg\\|gif\\|svg\\|webp\\|webm\\|css\\|js\\|woff2"
         :recursive t
         :publishing-directory "~/org/public_html/blog/"
         :publishing-function org-publish-attachment)

        ("blog-all"
         :components ("blog" "blog-static"))))

publishing-function 默认是 org-html-publish-to-html,用标准 ox-html 导出,而我自定义了导出后端 my-blog-publish-to-html

设置 time-stamp-file nil 可以避免每次执行 org-publish 的时候都在 HTML 里插入最新的时间信息,导致每次全部的 HTML 文件都有更改。

样式

Reset

html-head-include-default-style nil 去掉前面预览图中看到的样式,也就是默认内联的 CSS。然后引入我的 CSS 文件 style.css

:html-head-include-default-style nil
:html-head "<link rel=\"stylesheet\" href=\"/static/css/style.css\">"

接着在 style.css 中,我没有着急编写样式,而是参考 Josh Comeau 的 A Modern CSS Reset 撤销了浏览器默认样式(实际上这么说并不严谨,具体见文章 A pedantic note 部分)。浏览器默认样式在不同浏览器之间存在差异,重置之后从同一基准开始,可以保证样式一致。

字体和配色

字体霞鹜文楷Hack 都是自托管的 @font-face,不依赖 CDN,我相信 Cloudflare Pages 的速度。

配色依旧使用 Catppuccin,CSS 变量定义在 :root 里,所有的颜色都引用这些变量。

/* Fonts */
@font-face {
    font-family: "LXGW WenKai";
    src: url("/static/fonts/LXGWWenKai-Regular.woff2") format("woff2");
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Hack";
    src: url("/static/fonts/hack-regular.woff2") format("woff2");
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}

/* Variables */
:root {
    /* fonts */
    --font-body: "LXGW WenKai", serif;
    --font-mono: "Hack", monospace;

    /* catppuccin macchiato */
    --ctp-macchiato-rosewater: #f4dbd6;
    --ctp-macchiato-flamingo: #f0c6c6;
    /* ... */
}

布局

布局上我采用了三栏 grid:左侧元数据,中间文章正文,右侧目录。

三栏布局预览图

layout.png

在窄屏时会隐藏左右两个侧栏,退化成单栏,简单的响应式:

/* Responsive */
@media (max-width: calc(20ch + 2rem + 72ch + 2rem + 20ch)) {

    #content,
    #preamble {
        grid-template-columns: 1fr;
        padding-inline: 1rem;
    }

    #content>h1,
    #preamble nav,
    article {
        grid-column: 1;
    }

    aside,
    .toc-nav {
        display: none;
    }
}

其余的排版细节如代码块、引用、Admonition 等,都是在此基础上配色和间距的调整,不再赘述。

横向滚动表格

表格较为特殊,需要将宽度限定在内容栏里,过宽时横向滚动,但直接在 <table> 上添加样式无法实现。MkDocs 的做法是在 <table> 外套一个 wrapper div,在 div 上添加样式。

我仿照着,在 ox-publish 导出时用 filter 包裹 <div class="table-container">

(defun my-org-html-wrap-table-in-div (string backend info)
  (when (org-export-derived-backend-p backend 'html)
    (concat "<div class=\"table-container\">\n" string "\n</div>")))

(add-to-list 'org-export-filter-table-functions
             'my-org-html-wrap-table-in-div)

并在 CSS 里对 .table-container 应用 overflow-x

.table-container {
    overflow-x: auto;
}

索引页生成

ox-publish 本身不会自动生成索引页,主页、归档、标签、分类都需要自己解决。

最直接的方案是利用 :preparation-function,在发布前先生成对应的 index.org 文件,然后它们和普通文章一样被 ox-publish 发布成 HTML。

所有索引页的生成逻辑相同:读取 blog/posts/ 下所有文章的元数据(标题、日期、分类、标签),按规则排列成 Org 文件写入磁盘。my-blog-post-metadata 负责读取元数据,my-blog-generate-index 是通用的索引页生成函数,标签、分类、归档页都是它的调用:

代码
(defun my-blog-post-metadata ()
  (let ((posts-dir (expand-file-name "~/org/blog/posts/")))
    (delq nil
          (mapcar
           (lambda (file)
             (with-temp-buffer
               (insert-file-contents file nil 0 2000)
               (let (title tags-str category date description)
                 (goto-char (point-min))
                 (when (re-search-forward "^#\\+title:[ \t]*\\(.+\\)$" nil t)
                   (setq title (string-trim (match-string-no-properties 1))))
                 (goto-char (point-min))
                 (when (re-search-forward "^#\\+filetags:[ \t]*\\(.+\\)$" nil t)
                   (setq tags-str (match-string-no-properties 1)))
                 (goto-char (point-min))
                 (when (re-search-forward "^#\\+category:[ \t]*\\(.+\\)$" nil t)
                   (setq category (string-trim (match-string-no-properties 1))))
                 (goto-char (point-min))
                 (when (re-search-forward "^#\\+date:[ \t]*<\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" nil t)
                   (setq date (match-string-no-properties 1)))
                 (goto-char (point-min))
                 (when (re-search-forward "^[ \t]*:SUMMARY:[ \t]*$" nil t)
                   (let ((start (1+ (match-end 0))))
                     (when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
                       (setq description (string-trim (buffer-substring-no-properties start (match-beginning 0)))))))
                 (when title
                   (list :file file :title title
                         :tags (when tags-str (split-string tags-str ":" t))
                         :category category :date date :description description)))))
           (directory-files posts-dir t "\\.org$")))))

(defun my-blog-generate-index (index-file title-str keyword-fn &optional reverse)
  (let* ((index-dir (file-name-directory index-file))
         (flat (seq-mapcat
                (lambda (post)
                  (mapcar (lambda (key) (cons key post))
                          (funcall keyword-fn post)))
                (my-blog-post-metadata)))
         (grouped (sort (seq-group-by #'car flat)
                        (if reverse
                            (lambda (a b) (string> (car a) (car b)))
                          (lambda (a b) (string< (car a) (car b)))))))
    (make-directory index-dir t)
    (with-temp-file index-file
      (insert (format "#+title: %s\n" title-str))
      (insert "#+options: ^:{}\n\n")
      (pcase-dolist (`(,key . ,entries) grouped)
        (insert (format "** %s\n:PROPERTIES:\n:CUSTOM_ID: %s\n:END:\n\n"
                        key (my-blog-slugify key)))
        (dolist (post (sort (mapcar #'cdr entries)
                            (lambda (a b)
                              (let ((da (plist-get a :date))
                                    (db (plist-get b :date)))
                                (if (and da db) (string> da db) (not da))))))
          (insert (format "- [[file:%s][%s]]\n"
                          (file-relative-name (plist-get post :file) index-dir)
                          (plist-get post :title))))
        (insert "\n")))))

(defun my-blog-generate-tag-index (&optional _project)
  (my-blog-generate-index
   (expand-file-name "~/org/blog/tags/index.org")
   "标签"
   (lambda (post) (plist-get post :tags))))

主页与其他索引页略有不同,每篇文章需要显示日期、分类和摘要,还要有“继续阅读”链接。我用自定义属性 POST_DATEPOST_CATEGORY 携带这些信息,在自定义导出后端 +org-html-headline 里识别并生成对应的 HTML 结构:

代码
(defun my-blog-generate-homepage (&optional _project)
  (let* ((index-file (expand-file-name "~/org/blog/index.org"))
         (index-dir (file-name-directory index-file))
         (posts (sort (seq-filter (lambda (post) (plist-get post :date))
                                  (my-blog-post-metadata))
                      (lambda (a b) (string> (plist-get a :date) (plist-get b :date))))))
    (with-temp-file index-file
      (insert "#+title: 13m0n4de's blog\n")
      (insert "#+options: ^:{} toc:nil\n\n")
      (dolist (post posts)
        (let* ((file  (plist-get post :file))
               (title (plist-get post :title))
               (category    (plist-get post :category))
               (date        (plist-get post :date))
               (description (plist-get post :description))
               (parts   (split-string date "-"))
               (date-cn (format "%s年%d月%d日"
                                (nth 0 parts)
                                (string-to-number (nth 1 parts))
                                (string-to-number (nth 2 parts)))))
          (insert (format "* [[file:%s][%s]]\n"
                          (file-relative-name file index-dir)
                          title))
          (insert ":PROPERTIES:\n")
          (insert (format ":POST_DATE: %s\n" date-cn))
          (when (org-string-nw-p category)
            (insert (format ":POST_CATEGORY: %s\n" category)))
          (insert ":END:\n\n")
            (when (org-string-nw-p description)
              (insert (format "%s\n\n" description))))))))

(defun +org-html-headline (headline contents info)
    (let ((post-date (org-element-property :POST_DATE headline))
          (post-category (org-element-property :POST_CATEGORY headline)))
      (if post-date
          (let* ((title (org-export-data (org-element-property :title headline) info))
                 (href (replace-regexp-in-string
                        "\\.org$" ".html"
                        (or (org-element-map (org-element-property :title headline) 'link
                              (lambda (l) (org-element-property :path l)) info t)
                            "")))
                 (meta (if post-category
                           (format "%s · 分类于 <a href=\"/categories/#%s\">%s</a>"
                                   post-date (my-blog-slugify post-category) post-category)
                         post-date)))
            (format "<article class=\"post-summary\">\n<p class=\"post-meta\">%s</p>\n<h2>%s</h2>\n%s<a href=\"%s\" class=\"read-more\">继续阅读</a>\n</article>\n"
                    meta title (or contents "") href))
        (org-html-headline headline contents info))))

RSS

ox-publish 本身并不生成 RSS,我用了 ~taingram/org-publish-rss,配置如下:

(setq org-publish-project-alist
      `(("blog"
         ;; RSS
         :auto-rss t
         :rss-root-url "https://13m0n4de.pages.dev"
         :rss-title "13m0n4de's Blog"
         :rss-description "胡言乱语"
         :rss-link "https://13m0n4de.pages.dev"
         :rss-with-content all
         :rss-filter-function (lambda (file)
                                (string-prefix-p (expand-file-name "~/org/blog/posts/") file))
         :completion-function (org-publish-rss my-blog-run-pagefind))
  • :rss-filter-function:限定只有 posts/ 下的文章进入 RSS,排除索引页和关于页
  • :rss-with-content all:RSS 包含全文而不是摘要,我猜这样更适合纯 RSS 阅读器阅读

然后 rss.xml 作为静态文件由 blog-static 组件一并复制到输出目录。

(setq org-publish-project-alist
      `( ;; ...
        ("blog-static"
         ;; ...
         :include ("rss.xml")
         ;; ...

搜索

搜索通过 Pagefind 实现,博客中唯一用到 JavaScript 的页面。这也是无奈之举,考虑过用外部搜索引擎限定站点域名的方式,但完 全 没 有 收 录

izutsumi3.jpg

图3  伊兹津美(イヅツミ)《迷宫饭》

安装 Pagefind,索引中文需要开启 extended 特性:

cargo binstall pagefind --features extended

生成索引:

pagefind --site public_html/blog/ --glob "{about,posts}/*.{html}"

这条命令也可以作为 shell-command 添加到 :completion-function

然后在搜索页面使用 #+begin_export html 插入 Pagefind 的预制搜索界面:

#+begin_export html
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
        new PagefindUI({
            element: "#search",
            showSubResults: true,
            showImages: false,
        });
});
</script>
<div id="search"></div>
#+end_export

我在自己的 CSS 中也追加了一些自定义样式。

部署

目前 public_html/blog/ 是同一个 Git 仓库的另一个分支,每次发布后手动 push 这个分支,Cloudflare Pages 连接该分支,自动部署。

计划将代码搬到 publish.el 去,接入 CI,push 后自动触发发布。

Copyright © 2024 13m0n4de · CC BY-NC 4.0