在 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 导出 HTML 的 ox-html
支持多种 HTML 版本,不同版本会输出不同的 tag。我这里选择 HTML5 (fancy),因为:
- HTML5 里的
figure/figcaption
的 A11Y 应该相对好些。 -
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-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
其实如果这些 ID 是稳定的那倒还好,但 Org-mode 似乎每次导出生成的 ID 都不同。这就使这些“链接”很大程度上失去了意义。
当然,因为锚点 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
类的链接的处理覆盖成图片了,所以似乎并不支持。在我的版本里已经修复。下面是一些测试:
- External Link
- Post Link
-
Inline Image:
Figure 1: ablobtoiletflish.gif - External Inline Image:
- File Link: /robots.txt
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¶
本博客构建对应的 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
评论