当你不知道写什么时,就写你如何写作的。一定有哪一天,我也要分享“我为什么写作”了。
在这些人身上,你就看不到水往低处流、苹果掉下地,狼把兔子吃掉的宏大的过程,看到的现象,相当于水往山上流,苹果飞上天,兔子吃掉狼。我还可以说,光有熵增现象不成。举例言之,大家都顺着一个自然的方向往下溜,最后准会在个低洼的地方汇齐,挤在一起像粪缸里的蛆。但是这也不能解释我的行为。我的行为是不能解释的,假如你把熵增现象看成金科玉律的话。
当然,如果硬要我用一句话直截了当地回答这个问题,那就是:我相信我自己有文学才能,我应该做这件事。但是这句话正如一个嫌疑犯说自己没杀人一样不可信。所以信不信由你罢。
—— 王小波,《我的精神家园》
我体验过几乎市面上所有的热门 SSG:Jekyll、Hugo、Hexo、Zola、mdBook、MkDocs……每次迁移都是因为拓展难度——定制网站主题和功能太过复杂。
上次选择 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 就足够:
- mother fucking website
- better mother fucking website
- the best mother fucking website
- perfect mother fucking website
在 Org-mode 中,默认导出就是干净的语义化 HTML,内联一小段 CSS。字迹清晰、间距合适、颜色不刺眼、内容摆在无需转动脖子的位置、图片排版合理、有标题栏、有元信息,相当易于阅读,除了会在深夜刺瞎双眼,还有什么不满的呢?
ox-html 默认导出效果预览图(亮色图片注意)
至于 Markdown 格式本身,上篇文章已经说得够多了:没有真正的规范,变体繁多,可拓展性差。
相比之下,Org-mode 规范来自 Emacs 生态,不存在分裂的方言问题;原生支持脚注、表格、代码块、标签、元数据,不需要任何插件;导出时生成干净的语义化 HTML,没有多余脚本。
图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 的相关配置,您马上就要见到它了。
图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:左侧元数据,中间文章正文,右侧目录。
三栏布局预览图
在窄屏时会隐藏左右两个侧栏,退化成单栏,简单的响应式:
/* 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_DATE 和 POST_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 的页面。这也是无奈之举,考虑过用外部搜索引擎限定站点域名的方式,但完 全 没 有 收 录。
图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 后自动触发发布。