orgchange: 文学编程式 org mode 静态网页生成器

2023-08-19 六 22:07 2024-03-30 六 16:40
💡本文 "导出流程" 一节及以后仍属于个人随记状态,许多地方只是用于备忘

修改历史

  • [2024-03-29 五 21:08] "快速开始"和"导出选项"两节中增加了更多说明,纠正了不少错别字。 添加 "常见问题" 一节
  • [2024-01-31 三 22:24] 添加 org-ref 和 org-cite 引用导出原理说明
  • [2023-12-23 六 11:44] 添加 “各功能实现原理” 一节

本文介绍一种将所有配置都写在 org 文件中的 org mode 静态网页生成器: orgchange,这也是生成本 blog 的工具。

orgchange_overview.svg

设计理念

  • blog 相关配置应独立于个人 emacs 配置

    下载和安装 orgchange 后,blog 发布需要的所有工具和依赖都应该被 orgchange 目录包含,不依赖自己的 emacs 配置。因为 blog 的个性化部分不在 emacs 配置上体现,而是在文章内容的打磨和网站样式的设计上。

  • html 导出选项不应该主要限制在要发布的 org 文章里。

    emacs org export 默认情况下会读取文件内的 #+ 开头的选项,例如 #+OPTIONS: ^:nil p:t ,这使得用户可以对每一篇 blog 进行定制,但它带来的问题是,export 选项连同文章内容都写在同一个文件中,有时候这些配置会变得很长,比如 ox-hugo 也采用了这种方式,其官方的一个 展示文件 中,光设置博客导出的元信息就有有十几行,如下:

    #+hugo_base_dir: ../
    #+hugo_section: ./
    
    #+hugo_weight: 2001
    #+hugo_auto_set_lastmod: t
    
    #+title: Writing Hugo blog in Org (File Export)
    
    #+date: 2017-09-10
    #+author: Kaushal Modi
    
    #+hugo_tags: hugo org
    #+hugo_categories: emacs
    #+hugo_menu: :menu "main" :weight 2001
    #+hugo_custom_front_matter: :foo bar :baz zoo :alpha 1 :beta "two words" :gamma 10
    
    #+hugo_draft: true
    

    可以用 setup file 把这些 #+ 选项放在额外的一个文件中封装起来,但这又给每一个 org 文件引入了单独的配置文件,增加了管理复杂度。

    我个人倾向于只在 org 中保留最核心的元信息,如 #+title#+date 以及影响 emacs 交互的控制选项,如便于 org-roam 搜索的 #+filetags 、便于创建链接和跳转的 #+LINK: 等,除此之外尽可能只记录文章内容。

    关于各个网页发布的配置,如哪些文件需要发布、如何发布的信息,应该统一放在一个文件中,orgchange 利用 org mode 的层次结构(就如同编程语言中类继承的层次结构),把每个待发布文件的配置都写在该 blog 链接所对应的 org section 中。

  • 文学编程式的配置方法。

    orgchange 允许用户把自己对网站所有的期望都寄托于一个 org 文件中,而具体的参数选项则写在该文件的 python/elisp 代码块里,由于 org mode 的层次结构,我们可以对每一篇导出的文章做针对性设置,也可以对整个网页做全局的设置。

    可以参考 index org 的纯文本格式index org 的预览 了解更具体的写法:

  • 更现代的网页设计方式和默认导出选项

    样式的设计应该遵循更现代的 html5/css/js 编写和开发方式,而不是在 emacs 里用字符串拼接的方式嵌入 html 代码。 orgchange 把对网站的设计全部转到了 jinja2 模版和 css/js 编写上,如果用 flask 或者 django 编写过网页或对 jinja2 有所了解,可以立刻开始设计自己的网站主题。

  • blog 的发布目录独立于 org 文件目录,并且不对 org 文件的组织有固定要求。

    原生的 org-publish 只能指定某个目录作为网页发布目录,导出该目录下的 org 文件(可以使用 #+include 语句来间接导出其他目录下文件)。orgchange 则可以将系统里任意一篇 org 文件导入到指定的 web 目录下。

特色功能

  • 支持在 index org 的各个文章标题 section 内的 pythonemacs-lisp 代码块中设置导出属性
    • 例如 draft=True 表示只导出但不发布在 index.html 里
    • link_replace 可以指定替换 html 中本地文件链接的规则
    • 通过 bib_info 指定 bib 文件,用于导出参考文献。在 python block 里添加 bib_info 变量后,Org Mode 文件导出为 HTML 时,orgchange 会自动在文件的开头查找标题,并在标题下方添加 BibLaTeX 导出选项,使得导出的 HTML 页面中会自动显示参考文献列表。支持 org-ref 和 org-cite 格式的参考文献 csl 格式导出,内置 acl 和 ieee 两种 csl 样式
    • 在 python block 中为为每篇文章单独设置 categories(不同于 org 的 filetag)变量,从而自动生成 categories 页面。
  • 代码高亮:基于 pygments 进行代码高亮
    • 专为 lisp 系列语言提供 rainbow 括号高亮以及动态的括号内背景颜色高亮
  • 支持单个文件按一级标题导出成多个 html, 每个 html 共享整个 org 文件的全局目录(类似 readthedocs 的全局目录)。
  • 支持将多个 org 文件的目录进行打包,使得这些文章共享同一个全局目录,也与 readthedocs 类似。
  • 如果有 mermaid src_bock, 导出的 html 中自动添加 mermaid js cdn 链接
  • 支持在需要导出的 org 文件里添加一个 * self-apply 一节, 该小节内所有的 python 代码块会被执行,代码中的 soup 对象代表文章本身。这意味着你可以在 org 文章里直接插入一个 python 代码块,在其中用 beautifulSoup 代码直接修剪或装饰当前页面的 html 结构树,因此可以对每篇文章导出结构进行高度定制。

快速开始

首先安装 emacs (推荐 27 版本及以上) 和 python 依赖:

pip install orgparse jinja2 bs4 pygments

下载 orgchange 仓库到网站主目录下,比如 ~/mysite

mkdir ~/mysite; cd ~/mysite
git clone git@github.com:metaescape/orgchange.git

编写一个 org 文件,如 ~/org/mysite.org, 内容参考如下(推荐将其内容复制到新的 org 文件中,阅读并根据说明修改其中的内容,后文将该文件称为 index_org),将其中的二级标题里的链接改成本地自己想要发布的 org 文件的路径,同时将 org_prefixes 改成 org 文件共同的前缀

* my website :post:

 org 文件中添加一个一级标题并打上名为 =post= 的标签,
未来所有需要发布的网页以及发布选项都在此标题下编写 .

以下 python 块是网站发布全局选项
#+begin_src python
publish_folder = "~/mysite"
org_prefixes = ["~/org/", "~/myblog/"]
# 可以指定不同 index 模版
index_template = "~/mysite/orgchange/themes/darkfloat/index.html"
#+end_src

publish_folder 是网页的主目录org 导出的 html 结果文件会放在该目录下.
org_prefixes 用于过滤原始 org 路径中的前缀部分比如想要把 ~/org/posts/myblog.org 发布到
~/mysite/posts/myblog.html, 那么只需要在 org_prefixes 列表中加入 "~/org", orgchange
会根据该前缀识别出 ~/org/posts/myblog.org 的局部路径 -- "posts/myblog.org", 将其和
publis_folder 拼接得到 "~/mysite/posts/myblog.org", 替换后缀 =.org=  =.html= 作为发布目标路径
index_template 是网站主页的导出模板它放在主题目录 theme/darkloat 该变量也决定了整个网页的主题

可以在 org_prefixes 中添加多个过滤前缀因为有可能另外一篇 org 文件是在 "/path/to/blog/a.org",
而你想要将其导出到 "~/mysite/blog", 那么可以在 org_prefixes 里继续添加 "/path/to/"

index_template 是网站主页的导出模板它放在主题目录 theme/darkloat 

以下 elisp 块是 ox-export 的全局选项
#+begin_src emacs-lisp :results none :eval no
(setq org-export-with-section-numbers nil)
#+end_src

** [[~/org/post/my_first_blog.org][第一篇文章]]
将以上链接改成自己想要发布的本地 org 文件, 这篇文章将会发布到
~/mysite/post/my_first_blog.html

以下 python 块是当前文章发布的局部选项
#+begin_src python
categories = ["test"]
#+end_src

categories 表示当前文章的类别(或者叫作 tag), 因此该文章不单出现在主页如 www.xxx.com 也会出现在
www.xxx.com/categories/test.html , 一篇文章可以属于多个类别, 因此这是一个列表.

以下 elisp 块是当前文章发布时调用 ox-export 的导出选项,
如果有和全局选项相同的设置那么这里的优先级更高
比如这篇文章发布时会默认对每个 section 加上数字编号覆盖了全局的设置

#+begin_src emacs-lisp :results none :eval no
(setq org-export-with-section-numbers t)
#+end_src

** [[~/org/post/my_second_blog.org][第一篇文章]] :draft:
如果对文章链接设置了 draft 标签, 那么文章会被导出但是它不会被索引这包括
- 不会被写到网站主页 index.html 
- 不会被前一篇文章my_first_blog.html "下一篇" 链接

因此可以直接输入完整的域名访问它或者在本地进行预览
另外这些导出草稿的 html 路径会被记录在 =~/mysite/.orgchange.draft= 文件里
将本地网页推送到远程服务器上时可以设置 ignore 或者 exclude 来过滤该文件里的 html 路径
这样完全可以只作为本地预览当整篇文章完成后删除 draft 标签再正式发布

** [[~/org/post/my_test_blog.org][第一篇文章]] :noexport:

如果对文章链接设置了 noexport 标签, 那么文章会被导出但是它 =不会= 被导出

注意,除了 python  elisp 块中的内容其他形式的文字都不会影响导出
因此可以在这里书写任何关于这篇文章的注释信息比如是否要需要修改和另外一篇本地的笔记的关系等

执行以下命令:

python orgchange/publish.py --index ~/org/mysite.org --verbose --all

orgchange 会根据 ~/org/mysite.org 文件里设置的 org_prefixespublish_folder 变量名, 将 ~/org/post/my_first_blog.org 发布到 ~/org/mysite/post/my_first_blog.html ,使用默认的 darkfloat 主题

路径计算的规则:

  • 原始 org 文件路径为: ~/org/post/my_first_blog.org
  • 由于 org_prefixes 中有一个 "~/org/" 前缀能够与 org 路径前缀匹配,所以截取掉该前缀后得到 post/my_first_blog.org
  • 根据 publish_folder 的值 "~/mysite" 最终把 html 发布在 ~/mysite/post/my_first_blog.html

进入 ~/mysite 执行以下命令,打开 http://localhost:7777/ 预览网页

python -m http.server 7777

将 ~/mysite 推送到远程部署服务器上,比如:

rsync -avz --no-perms --no-owner --no-group --exclude='orgchange/example/*'  --exclude="venv/" --exclude="venv/" --exclude="__pycache__/" --exclude="*.json" --cvs-exclude  --exclude='.*' --delete $HOME/mysite/ root@webserver:/var/www/html

导出选项

对于一般的使用者, orgchange 导出网页的设置都是在 index_org 文件中,正如上节介绍所说,它分成 elisp 和 python 两类选项,这是因为 orgchange 先调用 emacs org-export 导出命令生成 html, 然后用根据 python 选项用 bs4 等 python 内的 web 相关工具去读取对应信息、修剪 html 、根据 jinja2 模板渲染出最终的 html 文件,除此之外,还可以额外渲染出其他辅助页面,例如 index, categories, about 等等。

接下来对这两类选项进行详细说明。

emacs lisp 设置选项

任何 org-export* 或 org-html* 变量都可以在 emacs-lisp org-block 里设置,例如:

(setq org-export-with-section-numbers t)

它们会在 emacs 执行 org-html-export-to-html 函数前被设置,这些选项可以放在全局 block 中也可以放在特定文章的 block 中,后者会在全局 block 代码执行完之后再执行,因此优先级更高。

以下列出可选参数的一部分以及它对应的 #+OPTIONS 缩写,更多请参考 emacs org mode 文档。

variable org option  
org-export-with-toc #+OPTIONS: toc:5  
org-html-doctype #+HTML_DOCTYPE: html5  
org-html-html5-fancy #+OPTIONS: html5-fancy:t  
org-export-with-smart-quotes ,:t  
org-export-with-emphasize *:t  
org-export-with-special-strings -:t  
org-export-with-fixed-width ::t  
org-export-with-timestamps <:t  
org-export-preserve-breaks \n:nil  
org-export-with-sub-superscripts ^:nil  
org-export-with-archived-trees arch:nil  
org-export-with-author arch:nil  
org-export-with-section-numbers num:nil  

除此之外,由于 index org 里 emacs-lisp block 的代码都会被 emacs 执行,因此理论上可以在其中编写更复杂的代码,比如通过设置 org-export-before-parsing-hook 对 org 文件进行预处理、覆盖 org-html-template 函数结构等,这使得用户能为每篇文章都进行最精细的导出定制。

不过这类函数级别的修改更推荐写在每个主题的特定 elisp 文件中,并且留出控制的变量接口,这样在 index_org 中仍然只需要设定变量值来进行定制。

python 设置选项

网站级别的选项涉及几大类: 前文说到 orgchange 先调用 emacs org-export 导出命令生成 html, 然后用 python 读取 html 并渲染出最终 的 html, 但这不是故事的全貌。由于 emacs 命令也是通过 python 调用的,因此 python 选项还能够决定一些更高层的设置,例如如果系统有多个 emacs 程序,可以指定特定版本的 emacs 来执行第一阶段导出

emacs = "~/codes/emacs-29.2/src/emacs" # 默认是 emacs, 自动寻找

这个设置不单可以是全局的,也可以针对特定文章,这样可以对比不同 emacs org 版本下的一些区别差别(尽管一般不会用到)

还包括前文提到的发布的目标文件夹,以及过滤前缀:

publish_folder = "~/codes/hugchangelife"
org_prefixes = ["~/org/logical", "~/org/design/web", "~/org/design/web/writing"]

另外网站主页 index.html 也是通过 jinja2 模板生成,因此许多展示在主页的信息也可以通过 python 变量来设置

# 可以指定不同 index 模版
index_template = "~/codes/hugchangelife/orgchange/themes/darkfloat/index.html"

beian = "粤ICP备2020003021"
github_url = "https://github.com/metaescape"
github_name = "oo"
site_repo = "https://github.com/metaescape/hugchange.life/"
user_name = "someone"
site_name = "Hugchange.life..."
site_email = "metaescape at foxmail dot com"

注意这些信息不单单会显示在主页,一般各个页面共享的 footer 或者 header 部分也可能继承(不同主题有不同基础样式,因主题而定),因此可以对每篇文章单独定制一些想要展示的信息。

以上提到的基本是全局设置,而由于 python 阶段会对 html 进行一些更精细的调整,因此这些文章级别的选项也由 python 来设置,例如:

link_replace = {"~/miniconda3/envs/torch2/lib/python3.11/site-packages/transformers":
                "https://github.com/huggingface/transformers/tree/main/src/transformers"}

这是一个字典,可以有多个值,每个元素的 key 是本地文件链接的前缀,value 是本地链接解析后 html 文件中希望展示的链接的前缀。它主要解决这样一类问题:在 org 中插入了一个本地的代码文件链接,这样可以通过 org 快速跳转并打开查看代码,但转换成 html 后本地文件地址对用户来说是无效链接,但如果该文件又是属于某个公开项目一部分,orgchange 就会根据 link_replace 里的规则对 html 里的路径进行替换,以展示公开的 url 链接。

另外,快速开始一节样例里提到的 categories 设置,这使得特定文章可以分别在 org 和 emacs 分类中找到。

categories = ["org", "emacs"]

再考虑这样的场景,你想写一系列算法和数据结构的文章,然后你希望在每篇在该系列中的文章的目录里,都可以看到同系列下其他文章的一级标题链接(类似 readthedocs 的全局目录),这样读者阅读完一篇之后, 可以从目录里看到该系列下的其他文章,那么只需要在 index_org 中对特定文章的 python 选项添加以下变量即可

anthology = "算法系列" # 相同合集的 org 文章会共享同一个目录

注意这与 categories 不一样,每篇文章只能属于一个系列(否则目录信息共享就会混乱了)。

考虑另外一个场景:你在 my_pyton_book.org 中记录了自己学习 python 的笔记,其中有多个章节,包括简介、语法基础、函数、面向对象四个一级标题,现在你想把它发布成一个多文件的 html, 那么可以给该文章添加 multipage_index 选项为 default, 如下图:

* my website :post:
** [[./my_python_book.org][ python 简易教程]]
#+begin_src python
 multipage_index = "default"  
#+end_src

此时 my_python_book.org 每个一级标题下内容会被导出一个单独的 html 文件, 统一放在 my_python_book 目录下:

mysite/my_python_book/
   - index.html # title 是简介
   - my_python_book_1.html # title 是语法基础
   - my_python_book_2.html # title 函数
   - my_python_book_3.html # title 是面向对象

这样,访问 mysite/my_python_book/ 目录会自动访打开 index.html, 也就是对应 org 文件中第一个标题下的内容,而这些 html 被默认看作是同一个 antholgy (合辑),每个 html 的目录中不单单只包括本文的章节索引,还会包括其他文章的标题索引。

其他选项说明及其说明如下:

#如果本文中有用 org-cite 或 org-ref 插入的文献,那么需要对本文指定 bib_info 才会将参考文献导出
bib_info = "~/org/lib/zotero.bib" 

# target_dir 是相对发布目录 publish_folder 的,相当于对特定文章继续指定一个子目录, 比如如果是 sysconfig, 那么文章会发布到 ~/codes/hugchangelife/sysconfig 下
target_dir = "sysconfig" 

# rainbow 是为了控制文章里导出的 lisp 系代码块里是否有彩虹高亮, 默认为 true
# 如果觉得眼花缭乱,可以改为 False, 也可以在全局设置
rainbow = False

常见问题

问:在 index_org 中修改了某篇文章的局部导出选项,例如修改了其 categories,要重新导出文章应该如何处理

答:如果标题中第一个字符是 "-", 例如 [[./my_first_blog.org][-第一篇 blog]],

重新执行 python orgchange/publish.py –index /path/to/index.org 后这篇 blog 会强制重新导出,之后再删除 "-" 即可。

问:在 index_org 中修改了某些全局导出选项,例如 footer 里的邮件地址,那么所有文章都要重新修改,应该如何处理?

答:由于 orgchange 没有缓存 emacs 原始导出的 html 文件,而第二步的 jinja2 渲染必须读取原始 html 文件,因此这要求所有文章都被重新导出一遍,通过以下命令强制更新:

python orgchange/publish.py –index /path/to/index.org –all

如果文章较多速度可能会较慢,后续考虑对原始 html 或者 soup 对象进行缓存,这样很多情况下只需要重新执行第二阶段的渲染步骤而不需要重新从 org 开始导出。

导出流程

orgchange_flow.svg

执行命令 publish.py --index index.org ,在处理完命令行选项后调用的核心函数是:

publish_via_index(index_org, verbose=args.verbose, republish_all=args.all)

它主要分成以下几部分:

  • extract_site_info_from_index_org 读取 index_org 文件里网站的全局配置和各个 post 的局部配置,返回一个 site_info 字典对象,其中 site_info["posts"] 是一个列表,每一个元素都是一篇文章的发布信息。
  • site_info["posts"] 中的每一篇文章通过 publish_single_file 函数调用 emacs 命令行将 org 导出成原始的 html 文件。
  • 调用 single_page_postprocessing 函数,读取各 html 文件,用 beautiful soup 从中提取出网页的各个模块,如 header, content, footer 等,然后将这些模块连同用户对 post 的信息一起交给 jinja2 模板生成工具,渲染出最终的 html.
  • 生成 index/category/about 等页面。

之后的章节都是对以上几个步骤的详细说明

1. index org 读取

本阶段从 index.org 中读取出所有需要发布的 blog 的信息,包括核心的 title 和路径等。

主函数是:

extract_site_info_from_index_org(index_org)

该函数找到 index_org 中第一个带有 "post" 的标题

  1. 调用以下函数读取网页全局配置

    if "post" in node.tags and node.level == 1:
        site_info = update_site_info(node, site_info)
    

    update_site_info 会对带 "post" tag 的一级标题下的内容进行解析,首先提取出其中的代码块,当前主要是支持 elisp 和 python 块

    src_blocks = extract_code_blocks(node.get_body())
    for language, content in src_blocks:
        if language == "python":
            variable_setting = get_bindings_from_text(content["body"])
            site_info.update(variable_setting)
        if language == "emacs-lisp":
            site_info["user_elisp"] = content["body"]
    

    对于 python 块的内容,直接调用 get_bindings_from_text 把其中的赋值转成字典

    def get_bindings_from_text(args):
        exec(args)
        return {k: v for k, v in locals().items() if k != "args"}
    

    因此,如果配置中有一个 python 模块如下:

    github_url = "https://github.com/metaescape"
    github_name = "mE"
    site_repo = "https://github.com/metaescape/hugchange.life/"
    

    那么 site_info 当前就会变成:

    {
    "github_url" : "https://github.com/metaescape"
    "github_name" : "mE"
    "site_repo" : "https://github.com/metaescape/hugchange.life/"
    }
    

    对于其中的 elisp 代码块,则直接以字符串的形式保存到 site_info["user_elisp"] 中,这些字符串会直接拼接到 emacs 导出命令行里。比如假设有以下 elisp 代码

    (setq org-export-with-section-numbers t)
    

    那么 post_info 又继续更新成:

    {
    "github_url" : "https://github.com/metaescape"
    "github_name" : "mE"
    "site_repo" : "https://github.com/metaescape/hugchange.life/"
    "user_elisp" : "(setq org-export-with-section-numbers t)"
    }
    
  2. 对 index org 中带 "post" tag 的一级标题下的每一个二级标题,执行 index_node_process 解析出每篇文章的发布信息。

    对于这些二级标题,如果带有 noexport 标签,那么整个子树不会被读取,如果带有 draft 标签,那么它会被认为是草稿, post_info["draft"] 为 True, 后文会根据该标签对其做特殊处理,一般来说 draft 不会显示在 index.html 的文章类表里(但这是可选的)。

    对于需要考虑发布的节点,解析该二级标题的过程和解析一级标题类似,比如都要去读取该标题下的 python 和 elisp 代码块,除此之外,还调用 post_title_path_prepare 去解析围绕该 org 文件的各种不同的路径:

    • org_path_abs2sys: org 文件在本机的绝对路径,这是发布的源路径,比如 "~/org/myblog.org"
    • html_path_abs2sys html 文件在本机的绝对路径,这是发布的目标,比如 "~/mywebsite/posts/myblog.org"
    • html_path_theoritical: html 文件所在的目录在本机的绝对路径,比如 "~/mywebsite/posts/"
    • html_path_rel2publish: html 相对于发布目录的相对路径,比如 "posts/myblog.org"
    • html_path_abs2www: 假设发布目录是根目录,那么 html 相对该目录的路径, 用于在 html 之间跳转, 比如 "/posts/myblog.org"

    这些路径在不同场合有不同的用处,比如该函数还会根据路径为 org_path_abs2sys 的 org 源文件是否比 路径为 html_path_abs2sys 的 html 目标文件的时间戳更晚,以决定是否要重新发布,如果要重新发布 post_info["need_update"] 会设为 True。为了能够实现如果 org 文件没有被修改,那么就不会重复导出的 cache 功能。

    对于二级标题,有一些常用的 python 选项如下:

    * my website :post:
    ** [[./my_first_blog.org][第一篇 blog]]
    #+begin_src python
     categories = ["emacs-org"] # 如果文中有 org-cite 或 org-ref 引用,需要指定 bib 文件
     bib_info = "~/org/lib/zotero.bib" # 添加标签
    #+end_src
    
  3. need_update_propagate: 单篇文章是否要更新的策略:

    • 如果在 post tag 下的标题中第一个字符是 "-", 例如 [[./my_first_blog.org][-第一篇 blog]], 那么这篇 blog 会被强制更新,这通常是用来调试代码或者只是修改了 index_org 里文章选项但没有修改文章后使用。
    • 如果该 org 文件,如 my_first_blog.org 的最新修改时间比它对应的 html 文件如 my_first_blog.html 更新,那么需要更新。

    该文章周边文章更新策略:

    • 如果 2 号 blog 的修改了,但它是 draft, 那么不需要扩散,因为 draft 不会被其他文章的 prev 或 next 引用,因此不更新。 一种顾虑是与该 draft 在同一个 Anthology 的其他文章可能需要同步目录信息,但目前不考虑这种情况,因为如果该 draft 和其他非 draft 在同一个 anthology, 那么默认该文章是要尽快发布的。只有那些独占一个 anthology 的 draft 文章是作为一种存档形式长期以 draft 形式存在,这种文章也没有必要触发 propagate
    • 如果 2 不是 draft,那么在它前后的 blog 1 和 blog 3 的 prev/next 也可能需要更新,因此更新属性是会传染的。orgchange 缓存下了各个文件的 prev 和 next 文章标题和路径, 只有 prev 和 next 中 title 和 link 与上一次不同时才要真正进行更新。(这里默认在 index.org 中,两个非 draft 文章之间不会有 draft 文章,如果有请移动到 post section 的开头或者结尾)
    • 但如果 blog 1 或 2 是 draft, 默认 draft 不需要 prev/next, 因此不需要更新。

2. 调用 emacs 命令将 org 导出成 html

本环节核心代码如下:

for post_info in site_info["posts"]:
    for category in post_info.get("categories", []):
        if category == "":
            continue
        # distinguish with categories in post_info
        site_info["categories_map"][category].append(post_info)
    if post_info["need_update"]:
        publish_single_file(post_info, verbose)
        post_info["soup"] = get_soups([post_info["html_path_abs2sys"]])[0]

首先搜集各个文章的标签,然后对于需要更新发布的文件,调用 publish_single_file ,它调用 emacs 命令行,通过 python 字符串的 placeholder 功能把发布的文章地址、用户指定的 elisp 选项都注入到该命令行中,如下所示:

cmd = [
     "emacs",
     "--batch",
     f"--chdir={theme_path}",
     "--load",
     "../general.el",
     orgfile,
     "--eval",
     elisp_code,
     "--kill",
 ]
 process = subprocess.Popen(
     cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
 )

注: ../general.elorgchange/theme/general.el 文件,其中定制了一些 html 默认发布选项,比如使用 html5 标准等等,使得发布出来的 html 文件更加符合现代标准。

执行完该函数后会在 html_path_abs2sys 路径上生成一个 html 文件,该文件没有 css/js 支持,只是应用 emacs org 的默认 parser 和 export 工具把 org 文件内容导出成了 html, 下一个阶段就是读取 html 的各个部分并且填充到 jinja2 模版中。

如果导出的是 multipage 页面,也就是一个 org 导出成多个 html 文件的情况,那么导出后会额外调用 multipages_prepare 函数,实现前文介绍过的单 org 文件对应多 html 的功能。该函数还会把该 org 对应的所有子 post 的信息添加到 "multipages" 入口中:

post_info["multipages"] = posts

general.el 的说明

'my/org-modify-cite-links 对导出 cite 进行统一,org-ref 使用双括号的方式,而 org-citar export 则使用单括号(并且用 & 而不是 @),所以这部分就是把 & 和双括号改成 @ 和单括号

org-export-each-headline-to-html 会对文件拆分成多个

3. jinja2 渲染阶段

single_page_postprocessing(site_info) 来承载,该函数分成以下几个小环节:

区分 draft 和正式发布文章

separate_draft_posts 根据每个 post_info 的 draft 属性,把需要发布的 post 分成两组:

site_info["all_draft_posts"] = all_draft_posts
site_info["all_visible_posts"] = all_visible_posts

注意 "all_draft_posts" , "all_visible_posts" 和 "posts" 的区别在于,前两个是包括 multipage 里的多个 子页面的 post_info 的,而 "posts" 中只包括 mutlipage 中 index.html 的 info 。

draft 文章会被发布,但是:

  • 它不会显示在 visible post 列表中,因此 index.html 默认不会显示
  • 它不会参与到 footer 的合并中
  • 但它会参与全局目录的合并,也就是说,如果 A,B 两篇文章属于同一个合集,其中 A 是正式的,B 是 draft, 那么从 A 的 global 目录中还是能看到 B 。

提取各文章 meta 信息

对于所有的博客文章(post),执行以下操作:

  1. 提取发布时间 :从每个 org 文件中的 #+DATE: 选项中获取用户填写的发布时间。
  2. 提取修改时间 :获取 org 文件的最后修改时间作为修改时间。
  3. 提取Emacs版本信息 :从 general.el 文件中的预设 postamble 中获取 Emacs 和 org 版本信息。

这些信息被保存在一个缓存字典中。缓存的目的在于优化性能和效率,具体原因如下:

  • 多处显示: 这些信息不仅显示在各个文章中,还显示在文章列表页,例如`index.html`和`category.html`。
  • 避免不必要的重新读取:如果文章(例如 a.org )没有修改,则不需要重新发布,也不需要重新读取 HTML 文件来提取这些元信息。
  • 更新时间戳: 如果另一篇文章(例如 b.org )被修改并重新发布,则 `index.html`中的 b.html 时间戳需要更新。这意味着整个`index.html`需要重新渲染。在这种情况下,系统需要其他所有 org 文件的时间戳,包括`a.org`, 但不能为了一个时间戳去重新读取所有 html 文件,因此缓存下来。
  • 缓存优化: 通过在每次导出文件后缓存这些文件的元信息,系统避免了为了获取少量元信息而重新读取 HTML 文件的需要。

对需要更新的文章进行 soup 修剪

对所有需要更新的文件调用 soup_decorate_per_html, 主要包括:

  1. 提取出 html body 中所有 <img> 标签,把其中的图片从 org 文件所在目录同步到 html 文件所在目录

    img_urls = [img["src"] for img in img_tags if "src" in img.attrs]
    
  2. 遍历 soup 中所有 <p> tag, 在其中找到 <a> 元素里的 href 属性:
    • 如果属性内容是以 file:// 开头,说明这是一个本地存在的文件的引用,但用户在网页上是访问不到的,这个链接一般会显示成绝对路径形式,把本地机器的文件路径完整暴露出来,因此要进行一些替换处理。
      • 首先标准化:

        (Pdb) os.path.normpath('file:///home/user/org/lib/mE.drawio')
        'file:/home/user/org/lib/mE.drawio
        
      • 其次,用户可以用字典来指定替换规则,如下例子,某篇文章里用 [[file:/path]] 的形式引用了某个 python 的库文件,而这个库是有官方 github 的,因此只需要把该路径的前缀替换成 github 链接,在网页发布后就可以跳转到对应的 https 链接了

        link_replace = {"~/miniconda3/envs/torch2/lib/python3.11/site-packages/transformers":
                        "https://github.com/huggingface/transformers/tree/main/src/transformers"}
        
      • 如果没有找到替换规则,那么对于相对路径,比如 ../xyz.org 直接删除相对符号变成 "/xyz.org" 后再替换 "~",如下:

        href = href.replace("..", "").replace("./", "").replace(os.path.expanduser("~"), "local")
        

        这样可以隐去本地的路径信息。

    • 替换 org-id: 由于每个 org 文件都是独立调用 emacs 命令发布的,因此如果 a.org 中通过 org-id 引用了 proj2/b.org 文件,那么被引用文件会被解析成 html 类型的绝对路径,因此要转换成网站路径。
  3. 调用 self_apply 执行 org block 中处理自身的代码。
  4. 调用 pygment_and_paren_match_all ,找到 dom 里的 <pre> 块用 pygment 对代码块进行高亮,对于 lisp 系和 #+name 为 jupyter-python 的代码块,会生成彩虹括号样式。
  5. 调用 mermaid_process : 如果 org 中有 mermaid 代码块,那么需要在 html 中加入额外的 mermaid.js 支持

可以看到,这部分是比较"脏"的活,细粒度地对各个 soup 内元素进行操作,包括替换或清理各种无效链接,修饰代码高亮等

生成全局目录

merge_anthology_toc(site_info) 函数会对每篇文章生成全局目录信息,全局目录是按照合订主题(anthology)整合的,也就是说,多篇拥有相同 anthology 的文章会共享一个全局目录。

假设在同一个 emacs 主题下的系列文章包括 a1.org, a2.org, a3.org, 你希望在每篇文章中都有一个全局目录,从该全局目录里能够跳转到同系列的其他文章(类似 readtheorgs 的目录),那么 org index 里的写法应该如下:

* my website :post:
** [[./a1.org][ emacs 系列第一篇 ]]
#+begin_src python
 categories = ["emacs-org"] # 添加标签
 anthology = "emacs"
#+end_src
** [[./a2.org][ emacs 系列第二篇 ]]
#+begin_src python
 categories = ["emacs-org"] # 添加标签
 anthology = "emacs"
#+end_src
** [[./a3.org][ emacs 系列第三篇 ]]
#+begin_src python
 categories = ["emacs-org"] # 添加标签
 anthology = "emacs"
#+end_src

以上三篇文章的 anthology 都属于 emacs 系列, 因此 merge_anthology_toc 函数会对同系列的三篇文章的目录进行合并,将最终目录保存在 post_info["global_toc"] 变量里

搜集相邻文章信息并用 jinja2 渲染

collect_prev_next_and_generate(site_info["all_draft_posts"], global_cache)
collect_prev_next_and_generate(site_info["all_visible_posts"], global_cache)

假设 index org 如下,有五篇文章,其中 a1 到 a3 是需要正式发布的,而 b1 和 b2 还没有写完,因此打上了 draft 标签。

* my website :post:
** [[./a1.org][ emacs 系列第一篇 ]]
** [[./a2.org][ emacs 系列第二篇 ]]
** [[./a3.org][ emacs 系列第三篇 ]]
** [[./b1.org][ org 系列第一篇 ]] :draft:
** [[./b2.org][ org 系列第二篇 ]] :draft:

orgchange 中,prev 表示的是更新的的文章(尽管一般来说 previous 语义表示是更旧的) 这个时候:

  • a1 的 prev 是空,next 则是 a2;
  • a2 的 prev 是 a1, next 是 a2;
  • a3 的 prev 是 a2, next 为空(与 b1 隔离了)。
  • b1 的 next 是 b2, prev 是空;
  • b2 的 prev 是 b1, next 为空。

注意 collect_prev_next_and_generate 函数会对文章提取 prev/next 信息。因为如果某篇文章被修改了,可以查看其 prev 和 next, 然后检查 prev.next 和 next.prev 中保存的 title 和 link 和当前修改文章的 tilte 和 link 是否一致,如果一致,prev 和 next 文章就不需要重新渲染。因此要在这里给 need_update_propagate 搜集 cache 信息,即 next/prev 里的标题和链接。

渲染完成后,缓存会被保存成 pickle 文件

with open(CACHE_PATH, "wb") as f:
    pickle.dump(global_cache, f)

cache 形式如下:

cache[html_path_abs2sys] = {
     "created": post_info["created"],
     "last_modify": post_info["last_modify"],
     "created_timestamp": post_info["created_timestamp"],
     "last_modify_timestamp": post_info["last_modify_timestamp"],
     "emacs_org_version": post_info["emacs_org_version"],
 }

在渲染完网页后会调用 save_draft_html_path_to_file 函数在 blog 主目录下保存一个名为 .orgchange.draft 的文件记录所有 draft post 的路径(相对发布目录路径),在 push 到远程服务器时,如果不想上传草稿,可以读取这个文件作为黑名单。

4. 生成 index/category 等页面

最后一步,从主题目录下找到 index.html 和 category.html, categories.html 的 jinja2 模板,把 site_info 发送给它进行渲染。

给 jinja2 模板提供的变量

在 python block 中设置的所有变量名都会在渲染时传给 jinja2 模版引擎。

全局变量:

  • publish_folder_abs2www: 导出的主目录路径(相对网站根目录)
  • site_repo, beian, github_name: python block 中用户设置
  • year: 自动计算

渲染每篇 blog 的变量:

  • header_titles: html 中包含 title 的 header 块
  • created_timestamp: 文章发布时间
  • last_modify_timestamp
  • org_main: html 中内容主体, <body> <div class=container> <main>
  • categories: 这是一个字典,key 表示文章类别,value 是该类别下所有文章的 post_info
  • prev: 上一篇文章的链接和标题,tuple
  • next: 下一篇文章的链接和标题,tuple
  • mermaid_script:

渲染 index.html, category.html 和 about.html 等全局页面的变量:

  • visible_posts: 所有非 draft blog 的 post_info

渲染 categories.html 页面的变量:

  • publish_offset: 和 publish_folder_abs2www 似乎没区别?
  • categories_len: 类别以及该类别中数量

about/meta 页面

只要在 index.org 中设置一个一级标题,并且打上 "about" tag, 其中的每个二级标题就会作为 categories/index.html 中的一个 subsection ,该二级标题下的所有内容会原原本本地写入到该 subsection, 因此如果要分段落,需要手动用 <p> 标签,如果要编写链接,则手动写 <a> 标签。

各功能实现原理

黑白主题切换的实现

  • 首先在网页 header 部分(右上角)定义一个 html tag 如下:

    <div class="toggle-button">
        <input type="checkbox" id="theme-toggle" />
        <label for="theme-toggle"> </label>
    </div>
    

    这是一个 checkbox 类型的元素,因为它能够维持 1 bit 的状态用于区分是深色还是浅色模式

  • 在 styles.css 中把该 <label> 元素调整成点击后左右滑动的样式(网络上很多现成实现,可以拷贝下来自己做小的修改) 这里要说明的是,在点击的视觉效果的实现上,css 都可以胜任,只是点击触发的逻辑需要以下的 js 代码来实现
  • 在 /orgchange/themes/static/main.js 中,有 themeBtnHandler 如下

    function themeBtnHandler(targetTheme) {
      const toggleBtn = document.getElementById("theme-toggle");
      toggleBtn.addEventListener("change", () => {
        if (toggleBtn.checked) {
          setTheme("light");
        } else {
          setTheme("dark");
        }
      });
      toggleBtn.checked = targetTheme === "light";
    }
    

    该函数会在 main.js 中另外一处对 DOMContentLoaded 事件的触发逻辑中被调用,调用代码如下:

    themeBtnHandler(localStorage.getItem("theme"));
    

    这里从浏览器持久存储存储空间读取变量 theme, 当第一次打开网页时,该变量不存在,会默认读取到 null 值, 执行 themeBtnHandler 之后,对 theme-toggle 按钮的监听启动的,并且以下语句会导致 toggleBtn.checked 被赋予 False, 即 theme-toggle 按钮没有被勾选,也就不会触发按钮 change 事件,从而维持默认的主题(当前默认是 dark)

    toggleBtn.checked = targetTheme === "light";
    
  • 当点击 checkbox 后,toggleBtn.addEventListener 监听到了 "change" 事件,这时候 toggleBtn.checked 已经变化了,因此触发 setTheme("light")
  • 在 /orgchange/themes/static/main.js 对函数 setTheme 的定义如下

    function setTheme(targetTheme) {
      let oppsiteTheme = targetTheme === "light" ? "dark" : "light";
      var codeThemeCss = document.getElementById("code-theme-css");
      if (targetTheme === "dark") {
        codeThemeCss.href = "/orgchange/themes/static/dark.css";
      } else {
        codeThemeCss.href = "/orgchange/themes/static/light.css";
      }
    
      const html = document.documentElement;
      if (targetTheme === "dark") {
        html.classList.remove("light");
      } else {
        html.classList.add("light");
      }
      localStorage.setItem("theme", targetTheme);
      rootStyle = getComputedStyle(document.documentElement);
    
      parenBackground = rootStyle.getPropertyValue("--paren-background1").trim();
    }
    

    该函数内容被空行分成了三个部分:

    1. 选中 head 中 <link id="code-theme-css"> 元素,根据当前主题设置对应的 css 文件
    2. 如果是 light 主题,在 <html> tag 里加入 class="light", 在主风格文件 style.css 里有如下内容:

      :root {
        --heading-color: #e8e8e8;
        --toggle-width: 5rem;
        --toggle-height: 2rem;
        --change-decorate: rgb(0, 86, 143);
        ...
       }
      
       :root.light {
         --heading-color: #0c0c0c;
         /* --main-background: #c7b28a; */
         --main-background: #b7a17684 url(paperTexture.webp);
         --header-background: #b9a278;
        ...
       }
      

      这里 :root 对应的就是 <html>, 对 :root.light 的样式设置就是对 <html class="light"> 对象的修饰,而该元素包含了整个文档。因此整个文档里的 –heading-color 等变量被重新设置

    3. 从 root 里提取出 –paren-background1 变量赋值给一个全局的 parenBackground 变量,这用于修改修改 light 模式下彩虹括号背景色。

    以上三点基本覆盖了黑白主题之间涉及的所有变化。

整个背景里纹理效果

这部分主要是参考 CSS Reset 网页,该 blog 的总体背景中,带有一点磨砂一样的纹理,作者是通过以下方式实现的:

  • 先对容器设置背景

    background: var(--_1xn0dsy1q);
    
  • 变量定义如下

    --_1xn0dsy1q: var(--_1xn0dsy1t);
    
    --_1xn0dsy1t: var(--_1xn0dsy8),linear-gradient(135deg,rgba(0,0,0,.6) 0%,transparent 60px),linear-gradient(to bottom,rgba(0,0,0,.6) 0%,transparent 80px),linear-gradient(to right,rgba(0,0,0,.5) 0%,transparent 20%),linear-gradient(to top,#000 0%,transparent 340px);
    
    --_1xn0dsy8: url(/images/noise.webp);
    

/images/noise.webp 是一个带噪点的透明背景图片,其尺寸是 100x100px, 大小是 2.7kb, 其中的渐变设置如下:

这段CSS代码定义了一个复合的背景图像,使用了多个`linear-gradient`来创建不同的效果:

  1. linear-gradient(135deg, rgba(0,0,0,.6) 0%, transparent 60px): 这创建了一个以135度角倾斜的线性渐变,从黑色(60%不透明度)渐变到透明,变化开始于60像素处。
  2. linear-gradient(to bottom, rgba(0,0,0,.6) 0%, transparent 80px): 这是一个从上到下的线性渐变,从黑色(60%不透明度)到透明,渐变在80像素处开始。
  3. linear-gradient(to right, rgba(0,0,0,.5) 0%, transparent 20%): 这是一个从左到右的线性渐变,从黑色(50%不透明度)到透明,变化开始于20%处。
  4. linear-gradient(to top, #000 0%, transparent 340px): 这是一个从下到上的线性渐变,从纯黑色到透明,渐变在340像素处开始。

这些渐变层叠在一起,创建了一个复杂的背景效果,可能用于创建阴影效果或者为背景图片添加暗色遮罩层。

因此可以从 CodePen - Transparent Textures 或者 Transparent Textures 选择自己喜欢的透明纹理进行替换即可,以上网站下载下来的一般是 png, 如果想用 webp 可以在通过 PNG to WEBP | CloudConvert 进行转换。

另外,这种方式只能设置纹理,如果直接在纹理上写文字视觉效果会比较差,因此应该用以下两层 div 来包裹正文,最外面是 texture, 内层则设置纯颜色背景(带一点点透明使得纹理能够显现出来)

<div class="texture-background">
    <div class="pure-color-background">
      <h1>title</h1>
      <p> something </p>
    </div>
</div>

mathjax 的处理

个人使用的是 emacs28.2 ,对应 org 版本是9.5.5,其 mathjax 的设置选项如下

<script type="text/x-mathjax-config">
    MathJax.Hub.Config({
        displayAlign: "center",
        displayIndent: "0em",

        "HTML-CSS": { scale: 100,
                        linebreaks: { automatic: "false" },
                        webFont: "TeX"
                       },
        SVG: {scale: 100,
              linebreaks: { automatic: "false" },
              font: "TeX"},
        NativeMML: {scale: 100},
        TeX: { equationNumbers: {autoNumber: "AMS"},
               MultLineWidth: "85%",
               TagSide: "right",
               TagIndent: ".8em"
             }
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS_HTML"></script>

主要问题是,该版本比较旧, 并且 cdnjs.cloudflare.com 几乎无法加载到,因此手动改成更新的版本,处理方法是,先在 general.el 里覆盖 org-html–build-mathjax-config 函数,如果检测到了有 latex 片段,不是插入 MathJax.js 引用,而是在 html 头里加一个 "need-mathjax" 的 placeholder 标签,如下:

(defun org-html--build-mathjax-config (info)
  "Insert the user setup into the mathjax template.
INFO is a plist used as a communication channel."
  (when (and (memq (plist-get info :with-latex) '(mathjax t))
	     (org-element-map (plist-get info :parse-tree)
		 '(latex-fragment latex-environment) #'identity info t nil t))
    "<script id=\"need-mathjax\"></script>"))

接着在 main.js 里加上检测该 id 对应的元素是否存在的代码,如果存在则加入 mathjax cdn 地址(如 bootcdn)和对应配置,具体参数见 /orgchange/themes/static/main.js。 这样做的好处是,单一篇文章已经发表很久了,此时想要更新 mathjax 的 cdn 地址,只需要修改 main.js 来更新, 完全不需要重新导出 blog 。

Mathjax | BootCDN 选择 js,其中提供了很多 Mathjax 的选项,AI 给出的选择标准是:

选择哪个MathJax JavaScript文件取决于你的特定需求:

  1. `tex-chtml-full-speech.js` 或 `tex-chtml-full-speech.min.js`: 如果你需要完整的TeX支持(包括所有TeX扩展)和屏幕朗读器的语音支持,请选择这个。`.min.js` 版本是压缩过的,文件更小,加载更快。
  2. `tex-chtml-full.js` 或 `tex-chtml-full.min.js`: 如果你需要完整的TeX支持但不需要语音功能,请选择这个。同样,`.min.js` 版本是压缩版。
  3. `tex-chtml.js` 或 `tex-chtml.min.js`: 如果你只需要基本的TeX支持(没有额外的TeX扩展),请选择这个。`.min.js` 版本是压缩版。

一般情况下,如果你不确定需要哪些功能,选择 `tex-chtml-full.min.js` 是个不错的起点,因为它提供了完整的TeX支持且文件体积较小。如果加载速度是一个考虑因素,优先选择任何带有 `.min.js` 后缀的版本。

org src block 的语法高亮

orgchange 使用 pygments 进行代码高亮,其原生就支持大量的主流编程语言,比如 python, css, bash 等

对于如下类型的 org src block 默认是没有语法高亮的,orgchange 将其看作 python 代码对待,因为其中的 *, #+ 等符号也是 python 里的特定语法对象,从而得到高亮。

* my website :post:
#+begin_src org
** [[./a1.org][ emacs 系列第一篇 ]]
#+end_src

代码块拷贝按钮

源代码 block 的拷贝按钮是 js 动态生成的,点击后拷贝也是 js 实现

如下,页面加载之后,调用 addCopyCodeButtons 航速,它会扫描 html 所有 class 名为 ".org-src-container" 的元素(这是 org export to html 默认的代码块名称),然后像其中添加一个类名为 copy-code 的 button 元素,元素里内容是一个粘贴的 unicode 符号加上代码块语言名称(语言直接写在 <pre> 的类名中,dom.py 的 pygment_and_paren_match_all 函数对此有部分修改,比如把 jupyter-python 块的名称改为 python), 并监听对该元素的 click 事件,如果点击则调用 copyCode 函数。

const copyLabel = "⎘ ";

function addCopyCodeButtons() {
  if (!navigator.clipboard) return;
  let blocks = document.querySelectorAll(".org-src-container");
  blocks.forEach((block) => {
    let button = document.createElement("button");
    pre = block.querySelector("pre");
    if (pre) {
      button.innerText = copyLabel + " " + pre.className;
    } else {
      button.innerText = copyLabel;
    }

    button.classList.add("copy-code");
    block.appendChild(button);
    button.addEventListener("click", async () => {
      await copyCode(block, button);
    });
    block.setAttribute("tabindex", 0);
  });
}
document.addEventListener("DOMContentLoaded", function (event) {
  addCopyCodeButtons();
});

copyCode 函数实现如下,这里主要就是调用浏览器提供的 navigator.clipboard.writeText(text); 提取 html 元素中的的文本到粘贴板,然后将 copy 按钮的内容临时改成 copied, 持续 0.5s 后还是回到语言名称来展示。

async function copyCode(block, button) {
  let pre = block.querySelector("pre");
  let text = pre.innerText;
  await navigator.clipboard.writeText(text);
  button.innerText = "copied";
  setTimeout(() => {
    button.innerText = copyLabel + " " + pre.className;
  }, 500);
}

在 styles.css 中则用 display: none; 使得 .copy-code 按钮内容默认不显示,只有鼠标放在代码框内才显示:

.org-src-container {
  position: relative;
  margin: 0 0;
}

.copy-code {
  display: none;
  position: absolute;
  top: 1rem;
  right: 1rem;
  background-color: #0000;
  border: none;
  border-radius: 5px;
  color: var(--heading-color);
}

.org-src-container:hover button {
  display: block;
}

如何把 org 代码块中的 #+name 显示出来?

如果有一个代码块是带 #+name 属性的,例如:

#+name: print_code
#+begin_src python
print(1)
#+end_src

org 在默认导出的时候是会删除这个属性的,但文章中有时会展示 noweb 形式的代码块引用且在 tangle 时不希望执行这些 noweb 引用,只希望是按类似如下的格式显示出来:

<<print_code>>

因此把 name 也在 html 中显示出来会比较方便

首先 org 有一个专门解析代码块的函数 org-html-src-block 函数,它的前几行内容如下:

#+name: myname
(if (org-export-read-attribute :attr_html src-block :textarea)
    (org-html--textarea-block src-block)
  (let* ((lang (org-element-property :language src-block))

这里调用了一个 org-export-read-attribute 函数,它可以读取 attr, name 属性和 attr 很相似

#+ATTR_HTML: :width 800 :align center
#+NAME:

因此先尝试 (org-export-read-attribute :name src-block) 来读取 name, 但发现这里返回的是 plist, 之后查看 org-export-read-attribute 的实现,发现其中有用 org-element-property 函数继续去获取属性,而以上代码中也有用该函数读取 :language 属性,因此用以下方式:

(name (org-element-property :name src-block))

发现可以正常读取到 #+NAME 属性,于是把 name 加入到最终 <pre> 元素的构建中:

(format "%s<pre><code class=\"%s\"%s>%s</code></pre>"
        (if name (format "<div class=\"pre-name\">name: %s </div>" name) "")
        lang label code 
        )

最后加入 css 修饰:

.pre-name {
  color: var(--secondary-text-color);
  font-size: 1.2rem;
  transform: translate(1rem, 0.8rem);
}

如何导出 org-ref 和 org-cite 文献列表?

在用户设置层面,如果要对文章进行导出,应在其 heading 下加入 bib_info 变量,这是一个元组,第一个是声明 bib 文件路径,第二个是文献导出的形式:

bib_info = "~/org/lib/zotero.bib", "ieee"
# bib_info = "~/org/lib/zotero.bib", "acl"

目前可用 acl 和 ieee 两种格式,分别是如下样式

acl 的引用格式
BERT 模型(Devlin et al., 2019)

acl 参考文献格式:
Jacob Devlin, Ming-Wei Chang, Kenton Lee, and Kristina Toutanova. 2019. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. In Proceedings of the 2019 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies, Volume 1 (Long and Short Papers), pages 4171–4186, Minneapolis, Minnesota. Association for Computational Linguistics.

ieee 的引用格式
以 ChatGPT 为例[2],...

ieee 的参考文献格式:
[2] L. Ouyang et al., “Training language models to follow instructions with human feedback,” Advances in neural information processing systems, vol. 35, pp. 27730–27744, Dec. 2022, Accessed: Jan. 03, 2024. [Online]. Available: https://proceedings.neurips.cc/paper_files/paper/2022/hash/b1efde53be364a73914f58805a001731-Abstract-Conference.html

对于写 blog 已经足够了。

publish.py/bib_hook_parse 函数会读取 bib_info 变量,将其转换成以下字符串,这是 elisp 代码

f"""
(add-hook 'org-export-before-parsing-hook
(change/org-make-add-bibliography "{bib_file}" "{csl_file}" "{cite_style}"))
"""

publish.py/publish_single_file 函数中直接把以上命令插入到最终要执行的 emacs 命令中:

elisp_code = f"""
(progn 
    (setq default-directory "{html_folder}") 
    {post_info.get('user_elisp')}
    {bib_hook_parse(post_info)}
    {final_export_elisp})
"""

该命令执行时会调用定义在 general.el 中的以下 elisp 函数,把 #+cite_export: 、 #+bibliography: 和 #+print_bibliography 插入到 org 后再执行 export

(defun change/org-make-add-bibliography (bib-file csl-file-path csl-style-option)
  (lambda (backend)
    (save-excursion
      (goto-char (point-min))
      (re-search-forward "^#\\+title:" nil t)
      (forward-line)
      (let ((current-point (point)))
        (goto-char (point-min))
        (unless (re-search-forward "^#\\+bibliography:" nil t)
          (goto-char current-point)
          (insert (concat "#+bibliography: " bib-file "\n"))
          (insert (concat "#+cite_export: csl " csl-file-path " " csl-style-option "\n")))
        (unless (re-search-forward "^#\\+print_bibliography:" nil t)
          (goto-char (point-max))
          (insert "\n* 参考文献\n#+print_bibliography:"))))
          ))

由于 org-ref 插入的 citekey 和 org-cite 的不一样,但导出时用的是 org 自带的 org-cite 中的导出模块,因此要在 export 前先把 org-ref 格式转成 org-cite 形式,以下函数加入到导出前的 hook 中来解决这个问题。

(defun change/org-modify-cite-links (backend)
  "Modify cite links in the current buffer for export."
  (goto-char (point-min))
  (while (re-search-forward "\\[\\[cite:&\\(.*?\\)\\]\\]" nil t)
    (unless (org-between-regexps-p "^#\\+begin_" "^#\\+end_")
      (replace-match "[cite:@\\1]" nil nil))))

(add-hook 'org-export-before-parsing-hook 'change/org-modify-cite-links)

multipage 各个页面的 title 是怎么生成的?

对于 multipage, 我希望的是:

  • 第一个 heading 会转换成 index.html, title 为 heading 内容, subtitle 为 #+tiltle/0
  • 第二个 heading 会转换成 folder_name_1.html, title 为 heading 内容, subtitle 为 #+tiltle/1
  • 以此类推

对 html 文件名的修改

在 general.el 的 org-export-each-headline-to-html 函数中,会对 org 的每个 heading 都进行拆分,调用的是 org-map-entries 函数,它类似一个 for 循环,在该文件设置一个全局的 multi-page-index 变量,简称 cnt,每次自加:

  • 当 cnt 为 0 时,设置其中的 filename 为 index.html
  • 当 cnt 非 0 的 i 时,设置其中的 filename 为 dir_{i}.html
(dirname (file-name-nondirectory
            (directory-file-name dir)))
(filename (if (= multi-page-index 0)
                (concat dir "/index.html")
                (concat dir "/" (format "%s_%s.html" dirname multi-page-index))))

对各个文件的 title 的修改

在 general.el 的 org-export-each-headline-to-html 函数中,会对 org 的每个 heading 都进行拆分,但全局的 #+ 选项在各个 heading 导出时都是有作用的,也就是说,#+title 会广播到每一个 heading 生成的页面中。 为此在 general.el 中定义了全局的 user-settings-blog-title 变量,在 org 文件的拆分阶段,每次把这个值设置为 heading 的内容,同时修改了 export.el 中对 org-html-template 里对 title 的判断,将原始的

(plist-get info :title)

改成了

(or user-settings-blog-title (plist-get info :title))

这样,只要优先设置了 user-settings-blog-title 变量,就会把每个 html 的 title 设置成 heading 的内容

对各个文件的 subtitle 的修改

在 export.el 中读取 multi-page-index 变量,如果是 nil 那么就用 buffer 设置的 subtitle, 如果不是 则设置为 title/index

(subtitle
 (if (null multi-page-index)
     (plist-get info :subtitle)
   (format "%s :: %s" (plist-get info :title) multi-page-index)))

如何实现彩虹括号

如果当前代码块是以下之一,同时页面变量 rainbow 为 True (默认为 True) 那么会调用 paren_match 算法来提取出括号,并且按括号的深度层级打上标签,这基本是一个基于栈的解析算法。

lisp_family = [
    "scheme",
    "lisp",
    "racket",
    "elisp",
    "emacs-lisp",
    "elisp",
    "jupyter-python",
]

其他说明

关于 org 导出 html 的参考资料

org 原生导出成 html 的原理可以参考 include-yy 博客里的 The Org Manual 13.9 全解语义元素与 org 的 html5 导出 等几篇与 org 导出有关的(非常细致)的文章。对 org-export 流程有了一个总体了解之后就可以带着具体的问题(比如如何能导出 org 代码的 #+name 标签)在 emacs 中直接查看相关函数的源码并针对自己想要修改或添加的功能做一些尝试。

关于 jinja2 模板的理解

对我来说,最初对 jinja2 的理解困难更多是概念上带来的,因为以前从来没接触过一种混在某种结构文本里的编程语言,甚至不知道这到底是用来做什么的,似乎没有什么必要。但用 python fstring 来作为类比可以很快越过概念理解的障碍。 jinja2 的模版可以看成是 python fstring 的扩充版本,当你需要打印某个字符串,但其中部分字符串是来自变量值或表达式结果时,就会用到 fstring

a = 1
b= 2
f"{a} + {b} = {a+b}"
1 + 2 = 3

然而对于个人 blog 网页来说, html 里大部分其实是文章内容,而 html 只是一个框架。比如,每篇 blog 都有一个一级标题,而标题就在各个 org 或者 markdown 文件中,因此当从 org/md 里读取到了标题之后,就可以通过以下方式来生成 html:

def render_blog(title):
    html = f"""<h1>{title}</h1>"""
    return html
    
for title in ["第一篇文章", "2022 总结"]:
    print(render_blog(title))
<h1>第一篇文章</h1>
<h1>2022 总结</h1>

当然实际中每篇文章不止有标题,但其他内容也是这样被填充到 fstring 模版里的, jinja2 把这些模版提取出来,封装成更方便且功能更丰富的接口(比如模版之间的继承、在模版里写 for 循环等)。

有了这层类比之后,对于 jinja2 的具体细节就可以查看文档或者 flask 教程里关于 jinja2 的部分。

关于 Pelican 和未来开发

在开发完这个包的基本功能之后,我发现了同样基于 python 的静态网页生成器 pelican, 它主要适用于对 Markdown 和 reStructuredText 的解析,它提到的几个功能和 orgchange 都很相似,比如:

  • Site themes (created using Jinja2 templates)
  • Code syntax highlighting via Pygments

因为 pelican 是个多人参与并相对成熟的项目,其中有大量的额外的插件,后续可以考虑使得 orgchange 能够支持其生态中的部分插件,比如 rss 生成,全站搜索等等。


如对本文有任何疑问,欢迎通过 github issue 邮件 metaescape at foxmail dot com 进行反馈