跳到主内容

在 Nikola 里使用 Org-mode 撰写博客

Nikola 有一个 Org-mode 插件基本上可以在博客里实现开箱即用的 Org-mode 支持。之前一直是用的 reStructuredText 或是 Markdown 写的文章;现在时隔一年多没更博客,脑子里也只剩 Org-mode 了,索性直接迁移到 Org-mode 上来。

我维护了一个自己版本的插件:nikola-orgmode,增加了部分的中文以及排版的支持。 Nikola 及插件安装方法较简单,直接跟着官网和插件页面走即可。下面主要是记录一些 Nikola 原版 Org-mode 插件没有覆盖到的个人配置,顺便也找机会测试一下各种语法的支持。

1. 基本设置

首先是一些比较简单明了的基本设置,都是装饰效果。

;; Org configs
(setq org-export-with-toc t
      org-export-with-section-numbers t)

Org-mode 的 org-export-use-babel 不太灵活,我更希望的是不指定 :exports 时以原文为准(即 (org-export-use-babel nil) ),而设置了 :exports 时需要按选项来。 但目前设置了 org-export-use-babel 后, :exports none/results 选项就无效了。 设置 :eval 选项默认为 never-export 似乎可以实现这样的功能:

(setq org-export-use-babel t)
(add-to-list 'org-babel-default-header-args
             '(:eval . "never-export"))
(add-to-list 'org-babel-default-header-args
             '(:exports . "both"))
(add-to-list 'org-babel-default-header-args:elisp
             '(:eval . "never-export"))
(add-to-list 'org-babel-default-header-args:elisp
             '(:exports . "both"))
;; Just don't warn about :eval never-export
(define-advice org-babel-check-evaluate (:around (fun info))
  (let ((inhibit-message t)) (funcall fun info)))

Emacs Lisp 也默认 (lexical-binding . t) (这个最好放在个人配置里):

(add-to-list 'org-babel-default-header-args
             '(:lexical . "t"))

另外,因为本博客用 GitHub Actions 部署 1,所以一些需要的包只能当场安装——后面的所有 use-package 都自带安装来源,其中 :vc 需要 Emacs 30。

(setq package-load-list '(all))
(package-initialize)

2. 更多 Block 类型

Org-mode 导出 HTMLox-html 支持多种 HTML 版本,不同版本会输出不同的 tag。我这里选择 HTML5 (fancy),因为:

  • HTML5 里的 figure/figcaptionA11Y 应该相对好些。
  • details/summary 在要贴冗长代码时挺不错(如本文后文中(9)); Org-mode 里自带 tab 折叠,但是想要 HTML 里也带有类似效果就需要自己实现一个 block 类型。

details/summary 其实有一个相关的包:org-special-block-extras (EmacsConf 2020 - Powering-up Special Blocks),但既然 ox-html 有相关功能那我还是直接用官方的吧:

(setq org-html-doctype "html5"
      org-html-html5-fancy t)

开启了之后 Org-mode 就支持将带 #+caption: 的图片导出成 figure 了,同时也会自动把 org-html-html5-elements 中的 special block 自动转换为对应元素,例如:

#+begin_details
#+begin_summary
折叠概要
#+end_summary

折叠内容
#+end_details

对应显示效果如下:

Special block 与 org-html-html5-elements 默认值

Org-mode 的 special block 指的是 #+begin_<xxx>#+end_<xxx> 这种标记包围的文本块。上面 org-html-html5-elements 里默认支持的包括:

article aside audio canvas
details figcaption figure footer
header menu meter nav
output progress section summary
video      

也可以用来手动插入视频:

#+attr_html: :controls t :src /images/cant-feel-like-wayland/peek-emacs-hanoi.mp4
#+begin_video
#+end_video

3. HTML 标题 ID 生成

Org-mode 里的 * Heading 这类的章节标题对应的当然是 HTML 里面的 h2, h3, ... 等标题标签。而为了使文章目录等的链接能够跳转到标题,Org-mode 会给标题自动生成一些 HTML 锚点 ID

<li><a href="#orgd47fa1a">1. Heading</a></li>
<li><a href="#orgf42fa61">2. 标题</a></li>

#org47fa1a, #orgf42fa61, ... 天哪🤢。总而言之这些标题 ID 都是随机字符串,我个人是不太受得了的2。好消息是,有人已经写了一个 ox-html-stable-ids.el 包来从 * Hello World! 这种标题里生成 hello-world 这样的 ID;而坏消息呢,是它不支持中文。要使用这个包只需要在 orgmode 插件里的 conf.el 里加入下面的 use-package 即可:

;; Semantic HTML heading ID
(use-package ox-html-stable-ids :ensure
  :vc (:url "https://codeberg.org/jkreeftmeijer/ox-html-stable-ids.el.git")
  :config (org-html-stable-ids-add)
  :custom (org-html-stable-ids t))

但作为一名 Emacs 用户,我们怎会满足于只支持英文?所幸 Emacs OS 完全可以让我们在无外部依赖的几十行代码里搞定这个功能3

;; Semantic ID for Chinese chars
(defvar pinyin-inverse-map
  (eval-when-compile
    (with-temp-buffer
      (quail-use-package "chinese-py" "quail/PY")
      (let ((decode-map (list 'decode-map)) (map (make-char-table nil)))
        (quail-build-decode-map (list (quail-map)) "" decode-map 0 most-positive-fixnum)
        (dolist (pair (cdr decode-map))
          (pcase-let ((`(,pinyin . ,chars) pair))
            (if (fixnump chars)
                (aset map chars pinyin)
              (cl-loop for zh-char across chars do
                       (aset map (string-to-char zh-char) pinyin)))))
        map)))
  "A char-table mapping from chars to their Chinese pinyin.")
(defun char-to-pinyin (c)
  (if-let* ((pinyin (aref pinyin-inverse-map c)))
      (concat " " pinyin " ")
    (string c)))
(define-advice org-html-stable-ids--to-kebab-case (:around (orig string))
  (funcall orig (mapconcat #'char-to-pinyin string)))

基本原理就是 Emacs 的 Quail 输入法里自带了中文拼音到大部分中文字符的映射;只要我们把反向的映射提取出来就可以用它来将中文转成拼音,用做 HTML 锚点了:

(mapconcat #'char-to-pinyin "你好世界!")
ni  hao  shi  jie !

但是当然这里没有处理多音字,因此:

(mapconcat #'char-to-pinyin "脚注")
jue  zhu
2

其实如果这些 ID 是稳定的那倒还好,但 Org-mode 似乎每次导出生成的 ID 都不同。这就使这些“链接”很大程度上失去了意义。

3

当然,因为锚点 ID 除了不能有空格之外其它 Unicode 都是可以的。你也可以用简单的 advice 在清掉空格之后直接把中文当作 ID

4. 移除换行空格

老生常谈,请见 Emacs相关中文问题以及解决方案 - 中文断行,这里直接抄了 zwz 的方案:

;; Line breaks
(defun clear-single-linebreak-in-cjk-string (string)
  "clear single line-break between cjk characters that is usually soft line-breaks"
  (let* ((regexp "\\([\u4E00-\u9FA5]\\)[ \n]+\\([\u4E00-\u9FA5]\\)")
         (start (string-match regexp string)))
    (while start
      (setq string (replace-match "\\1\\2" nil nil string)
            start (string-match regexp string start))))
  string)
(defun ox-html-clear-single-linebreak-for-cjk (string backend _info)
  (when (org-export-derived-backend-p backend 'html)
    (clear-single-linebreak-in-cjk-string string)))
(add-to-list 'org-export-filter-final-output-functions
             'ox-html-clear-single-linebreak-for-cjk)

5. 正确导出文件间的链接

Nikola 里的链接一般用的是它自定义的 magic link: link://slug/post-slug 。但是, 说实话 Org-mode 里最常见且有各种配套支持的链接还是 file:some-file.org 这种, Nikola 的链接实在是太难用了。现在我的配置大概如下,之后看看开个 PR。(汇报:PR 已经提交了,但过了四个月没有任何回复,不知何时能 merge。)

为了能够用 GitHub Actions 部署,需要在个人配置或是 .dir-locals.el 里加入 org-link-file-path-type 的配置,否则部分跨目录的链接不会以相对路径保存,到时没法处理:

(setq org-link-file-path-type 'relative)

.dir-locals.el:

((org-mode . ((org-link-file-path-type . relative))))

因为原来的 Nikola 插件不知为什么把 file 类的链接的处理覆盖成图片了,所以似乎并不支持。在我的版本里已经修复。下面是一些测试:

6. 实现脚注位置自由

在使用 ox-html 导出时,Org-mode 里的脚注只支持放在网页的最后。我个人比较喜欢把脚注和引用位置排得更近一些 (4 但我不喜欢 Tufte CSS 移动端上那种离得太近的) , 而下面这一大段代码都是在干这件事情:

;; Footnotes
(defvar footnote-definitions (make-hash-table :test 'eq))
(defun org-nikola--find-footnote-def (footnote info)
  (let ((footnote-defs (with-memoization
                           (gethash info footnote-definitions)
                         (org-export-collect-footnote-definitions info)))
        (inner (cddr footnote)))
    (cl-block loop
      (cl-dolist (def footnote-defs)
        (when (eq (nth 2 def) inner)
          (cl-return def))))))
(defsubst org-footnote--label-id (label n)
  ;; Do not assign number labels as they appear in Org mode
  ;; - the footnotes are re-numbered by
  ;; `org-export-get-footnote-number'.  If the label is not
  ;; a number, keep it.
  (if (and (stringp label)
           (equal label (number-to-string (string-to-number label))))
      n
    label))
(defun org-nikola-footnote-definition (footnote _contents info)
  (pcase-let ((`(,n ,label ,def) (org-nikola--find-footnote-def footnote info)))
    (setq label (org-footnote--label-id label n))
    (let ((anchor (org-html--anchor
                   (format "fn.%s" label)
                   n
                   (format " class=\"footnum\" href=\"#fnr.%s\" role=\"doc-backlink\"" label)
                   info))
          (contents (org-trim (org-export-data def info))))
      (format "<div class=\"footdef\">%s %s</div>\n"
              (format (plist-get info :html-footnote-format) anchor)
              (format "<div class=\"footpara\" role=\"doc-footnote\">%s</div>" contents)))))
;; Add footnote definition handler
(let* ((backend (org-export-get-backend 'html))
       (transcoders (org-export-backend-transcoders backend)))
  (setf (org-export-backend-transcoders backend)
        (cons '(footnote-definition . org-nikola-footnote-definition)
              transcoders)))
;; Handle inline footnotes
(define-advice org-html-footnote-reference (:around (orig footnote contents info))
  (let ((def (org-nikola--find-footnote-def footnote info))
        (ref (funcall orig footnote contents info)))
    (if def
        (let ((label (org-footnote--label-id (cadr def) (car def))))
          (format "<span id=\"fn.%s\" class=\"inline-footdef\"> (%s %s) </span>"
                  label ref contents))
      ref)))
;; Disable footnote sections
(define-advice org-html-footnote-section (:override (&rest _)) nil)
;; In case you have a section named "Footnote", setting a different
;; `org-footnote-section' at build time should prevent the advice from hiding
;; your "Footnote" section.
(setq org-footnote-section "this section is disabled and should never show up")

7. 标识 #+RESULTS: 内容

Org-mode 的代码块可以通过 org-babel 执行,其执行结果会使用 #+RESULTS: 进行标识。 那么我们当然会想要使用 CSS 将与代码和执行结果区别起来。

;; Add a "results" CSS class to common blocks when they are marked as #+RESULTS
(defvar ox-nikola--block-is-results nil)
(dolist (ox-block '(org-html-example-block
                    org-html-quote-block
                    org-html-src-block))
  (advice-add
   ox-block :around
   (lambda (old-block block contents info)
     (let ((ox-nikola--block-is-results
            (org-element-property :results block)))
       (funcall old-block block contents info)))))
(define-advice org-html--make-attribute-string (:around (old-maker attributes))
  (when ox-nikola--block-is-results
    (let ((class-val (plist-get attributes :class)))
      (setq attributes (plist-put attributes :class
                                  (if class-val
                                      (concat "results " class-val)
                                    "results")))))
  (funcall old-maker attributes))
;; Add a "results" CSS class for fixed-width blocks (lines prefixed with ": ")
(defconst ox-nikola-assert-fixed-width-html
  "<pre class=\"example\">")
(define-advice org-html-fixed-width (:filter-return (html))
  (if (string-prefix-p ox-nikola-assert-fixed-width-html
                       html)
      (concat "<pre class=\"results example\">" (substring html (length ox-nikola-assert-fixed-width-html)))
    (error "unexpected fixed-width html")))

我的 nikola-orgmode 插件已经加入了上面的改动,但其实这个代码对于使用 Org-mode 来生成 HTML 的都会有效。官方的 Nikola 的插件重写了 org-html-src-block 函数来使用 pygments 来进行高亮。因此如果用的是官方原来的插件的话,还需要改动一下 init.el 里面的内容。

8. Tangle!

其实这篇文章原本是打算用 #+include: ... 来直接引用 conf.el 里的内容的,但是奈何对于非 .org 文件来说似乎 #+include 只能写死行号,不能靠 regexp 来筛选片段。最后变成了这样直接用 org-babel-tangle 反过来直接生成配置文件。因此后续再折腾也不用担心文档过时了(

9. Footnotes

1

本博客构建对应的 GitHub Workflow YAML 如下。为了方便使用建议用一个 Python 的包管理器来控制 Python 版本以及 Nikola 的更新和插件依赖。

GitHub Workflow YAML

name: Deploy site to Cloudflare Pages

on: [push]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        name: Clone repo
        with:
          fetch-depth: 0
          submodules: 'recursive'
      - name: Restore timestamps
        uses: chetan/git-restore-mtime-action@v2
      - uses: actions/cache@v4
        name: Cache pages
        with:
          path: |
            cache
            .doit.db
          key: ${{ runner.os }}-nikola-${{ hashFiles('**/conf.py', 'plugins/**') }}

      - uses: actions/setup-node@v4
        name: Setup Node.js
        with:
          node-version: 20

      - uses: pdm-project/setup-pdm@v4
        name: Setup PDM
        with:
          python-version: 3.12
          cache: true
      - name: Install dependencies
        run: pdm install

      - name: Install Emacs
        uses: purcell/setup-emacs@master
        with:
          version: 30.1

      - name: Download dart-sass
        uses: robinraju/release-downloader@v1
        with:
          repository: "sass/dart-sass"
          latest: true
          filename: "dart-sass-*-linux-x64.tar.gz"
          tarBall: false
          zipBall: false
          extract: true
      - name: Install dart-sass
        run: echo "$PWD/dart-sass" >> $GITHUB_PATH

      - name: Install uglifiers
        run: npm install -g uglify-js postcss postcss-cli cssnano

      - name: Build
        run: |
          source .venv/bin/activate
          emacs -batch -L plugins/orgmode -f batch-byte-compile plugins/orgmode/*.el
          nikola build
          nikola build # something might be wrong with the SASS plugin
      - uses: cloudflare/wrangler-action@v3
        name: Deploy pages
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: >
            pages deploy public --project-name=kyo

评论

This one-pixel image serves as a page view tracker and nothing more. This feedback keeps me motivated, because very few people ever comment.