用 Org Mode 写博客并通过 GitHub Action 部署到 Cloudflare

前言

具有折腾和重复造轮子癖好的我反复尝试了各种各样的博客工具,挥霍时间折磨自己。

最开始先是直到了原来可以用 markdown 来写博客,于是在网上找到了 Gridea, 格局一下子就打开了。撰写 md 、生成博客框架、部署,一条龙服务到家。只可惜当时已经入坑 Emacs , 于是尝试使用 Org Mode 来写博客。

事实上, Org Mode 就具有 Publish 功能,只是它不是一套方案,需要自己定制如何去生成整个架构。当时年幼无知的我,被 elisp 源码和 css 样式拒在门外。

随后发现了 Hugo (见《基于 Hugo + Org Mode + Github Page 的静态站点搭建》),它对 Org Mode 支持并不友好,并不适配主题。于是乎用了 ox-hugo 这个包,它把 org 转换适配的 md. 用起来是很好的,只是这样的工作流多了一个中间层,也就是才 org 到 md, 再到 html. 明明 Org Mode 是支持直接生成 HTML 的。

于是我参照 org-stattic-blog 这个包,自己糊了一个包,用起来很好,完完全全自定义,哪里不好改哪里。但是写的过程很痛苦,改起来其实不太好弄,因为整个过程是几乎是自己重新建构的,包括增量生成,只有底层从 org 导出 html 那个函数用的是 Org Mode. 直到上个月,使用的时候报错,没有很好的 debug 信息,于是打算放弃了。

然后看到了 Nikola, 一个 Python 写的静态博客生成工具,它有 Org Mode 的插件-—整个 Nikola 都做到了模块化,插件完全可以自己写,这一点非常赞。但终究还是脱离 Emacs 和我用的笔记包 denote, 所以弄了一下就放弃了。

终于,非复吴下阿蒙的我,重新拾起了 Org Mode 的 Publish, 前面写的 org-static-blog 的 fork 已经让我的能力大增,于是就有了今天的这篇文章。不可能完完整整地记录下整个过程,因为我是集成了 denote 笔记管理包,用到了里面的函数,把它讲一遍也不太现实。因此我主要是记下思路,具体的代码可以到 GitHub 中的仓库去看。

如有不足,欢迎指正。

Org Publish

官方文档:Publishing (The Org Manual), 建议先把它读完,整个过程还是比较清晰。

基本配置和使用

首先,发布 (publish) 以项目 (project) 作为单位,配置是把它记录在变量 org-publish-project-alist 中。一个项目至少包含源地址、目的地址,因此有:

(setq org-publish-project-alist
      '(("org"                            ; 项目名
         :base-directory "~/org/"         ; 源地址
         :publishing-directory "~/public" ; 目的地址
         )))

随后就可以通过 M-x org-publish 并选择 org 来发布该项目。一个项目是由一个作为项目名的字符串,以及一连串的属性构成,这些属性就表明了要如何去控制整个发布过程。

属性列表

这里给出一些重要的属性,一大部分属性在文档中已经给出,但是全部属性,需要在源码中去找。

  • base-extension: 文件后缀,指定源目录下哪些文件需要发布。默认是 "org" ,它是一个正则,如果匹配图片可以用“或”符号连接: "jpg\\|png"
  • exclude: 一个正则表达式,名字匹配的文件不发布
  • include: 一个列表,里面是字符串,指定哪些文件一定要发布,它的优先级最高。通过和 exclude 来配合做到单独发布,如首页等页面。
  • publishing-functoin: 发布函数,执行每个文件的的生成过程。传入三个参数, plist 是属性列表,整个发布过程的信息都存储其中,当然包括上面这些属性; filename 当前要发布的源文件的绝对路径; pub-dir 为发布的目录。默认是 org-html-publish-to-html.
  • preparation-function, completion-function: 函数或者一个函数列表,分别在项目启动前和项目完成后调用,传入属性列表 info (和 plist 是同一个东西,不同的变量名罢了)。可以完成一些前置的文件生成,或者后续的文件处理。

博客架构

首先通过不同的项目来搭建博客的框架。

基本属性列表

对于所有的页面,会有一些通用的属性需要设置,比如说 <head> 中的样式。定义一个属性函数,它提供公共属性,并把传入的属性拼接,返回拼接后的属性列表。

(defcustom blorg-base-plist
  (list
   :section-numbers nil
   :with-toc nil
   :with-entities nil
   :html-head-include-default-style nil
   :html-preamble 'blorg-html-preamble
   :html-postamble 'blorg-html-postamble
   :with-sub-superscript nil
   :html-footnote-format "<sup class=\"fsup\">%s</sup>"
   :html-self-link-headlines t
   )
  "Common Property list of Blorg.")

(defun blorg-compose-project (proj-name &rest args)
  (let ((base-plist (ht<-plist blorg-base-plist)))
    (ht-update base-plist (ht<-plist args))
    (cons proj-name (ht->plist base-plist))))

定义模块项目

(setq org-publish-project-alist
      (list
       (blorg-compose-project
        "blorg-post"
        :base-directory denote-directory
        :publishing-directory blorg-output-dir
        :recursive t
        :with-toc t
        :publishing-function 'blorg-post-publishing-function
        :preparation-function
        (lambda (plist)
          (advice-add 'org-publish-get-base-files :filter-return
                      'blorg-post-filter-files)
          (add-hook 'org-publish-after-publishing-hook
                    'blorg-post-add-tag-and-category-to-changed-files))
        :completion-function
        (lambda (plist)
          (advice-remove 'org-publish-get-base-files
                         'blorg-post-filter-files)
          (remove-hook 'org-publish-after-publishing-hook
                       'blorg-post-add-tag-and-category-to-changed-files)))

       (blorg-compose-project
        "blorg-index"
        :base-directory blorg-cache-dir
        :publishing-directory blorg-output-dir
        :exclude ".*"
        :include '("index.org")
        :preparation-function 'blorg-assemble-index
        :blorg-type "index"
        :with-title nil)

       (blorg-compose-project
        "blorg-taxonomy"
        :base-directory blorg-cache-dir
        :publishing-directory blorg-output-dir
        :exclude ".*"
        :include '("taxonomy.org")
        :preparation-function 'blorg-assemble-taxonomy
        :blorg-type "taxonomy")

       (blorg-compose-project
        "blorg-category"
        :base-directory (f-expand "category" blorg-cache-dir)
        :publishing-directory (f-expand "category" blorg-output-dir)
        :blorg-type "each-taxonomy"
        :preparation-function
        (lambda (_)
          (blorg-assemble-each-taxonomy "category" _)))

       (blorg-compose-project
        "blorg-tag"
        :base-directory (f-expand "tag" blorg-cache-dir)
        :publishing-directory (f-expand "tag" blorg-output-dir)
        :blorg-type "each-taxonomy"
        :preparation-function
        (lambda (_)
          (blorg-assemble-each-taxonomy "tag" _)))

       (blorg-compose-project
        "blorg-archive"
        :base-directory blorg-cache-dir
        :publishing-directory blorg-output-dir
        :exclude ".*"
        :include '("archive.org")
        :blorg-type "archive"
        :preparation-function 'blorg-assemble-archive)

       (blorg-compose-project
        "blorg-asset"
        :base-directory (expand-file-name "assets" denote-directory)
        :publishing-directory (expand-file-name "assets" blorg-output-dir)
        :base-extension "jpg\\|gif\\|png"
        :publishing-function 'blorg-image-publishing-function)

       (blorg-compose-project
        "blorg-static"
        :base-directory (expand-file-name "static" denote-directory)
        :publishing-directory (expand-file-name "static" blorg-output-dir)
        :base-extension "ico\\|css\\|js\\|svg\\|woff\\|woff2\\|otf"
        :recursive t
        :publishing-function 'org-publish-attachment
        :preparation-function
        (lambda (_)
          (let ((in (f-expand "style.scss" denote-directory))
                (out (f-expand "static/style.css" denote-directory)))
            (when (or (not (f-exists-p out))
                      (blorg-file-is-older-p out in))
              (shell-command-to-string
               (format "sass %s %s" in out))))))))
  • blorg-post

    发布文章, recursive 涵盖了笔记目录下所有的的 org 文件。

    with-toc 会生成标题目录,对于不想生成目录的文章,只需在 org 文件中加上 #+options: toc:nil 即可。

    使用的发布函数是 blorg-post-publishing-function, 主要工作是清空当前上一篇文章发布时产生的变量。

    准备函数在 org-publish-get-base-files 设置了一个钩子 blorg-post-filter-files, 前者是去获取源目录下的文件的函数,这里相当于添加了一个过滤,把符合条件的筛选出来。org publish 基本是只能通过文件后缀去获取文件,而我用 denote 有标签,只有含有 postpage 标签的文章才发布。

    最后在完成函数中把钩子去掉,不影响后面项目的生成。

    同时注意到,我还设置了一个钩子 blorg-post-add-tag-and-category-to-changed-files. org publish 自带增量检查,只发布修改过的文章。对于比如标签页、分类页,我当然也希望它只发布相关的标签或分类,而不是全部都重新发布一遍。

  • blorg-index/blorg-archive

    这两个的功能类似极其类似,都是生成单个页面,都是一系列文章的集合。

    因为是单个页面,所以在指定目录下,需要先 exclude 所有的文章,再通过高优先级的 include 把这个文件单独引入。

    这类聚合类的页面,我们是没有源文件的,即我不会为了我的博客单独写一个 index.org, 因而这份工作就放在项目的准备函数里面;准备函数负责在暂存目录下生成一个 index.org/archive.org ,然后项目在把这个单独的文件发布。

  • blorg-taxonomy/blorg-tag/blorg-category

    这三个项目是:

    • 标签分类页,用于展示博客所有的标签和分类
    • 标签页文章:含有当前标签的文章列表
    • 分类页文章:属于当前分类的文章列表

    同样,三个都需要在准备函数中生成,第一个是单独的页面,只需要一个 taxonomy.org 即可。后面两个每个标签或分类对应一个页面,因此需要生成一系列的文章。在准备函数中生成, publishing-directory 指向对应目录,就相当于正常把文章发布出来。

  • blorg-asset/blorg-static

    静态文件项目,第一个是文章中引用的图片的目录,第二个是页面的静态文件,如 css, js, 一些字体。

    publishing-function 指定为 org-publish-attachment, 它执行的操作就是简单地把未出版或修改过的文件复制到发布目录。对于图片我加了一个操作,就是让它复制过去后的名字归一化,都使用它们源文件的名字的 md5 摘要作为发布文件名。

    我在 static 的准备函数中加入了一个命令,用来把 scss[1] 文件转换成 css 文件。

Org Html

至此,一个完整的项目生成过程应当已经清楚了,知道发布过程怎样开始的、怎样结束的。但是有些东西需要精细化地调整,比如说,文章中对另一篇文章的引用,生成的 html 的 a 标签指向的是本地地址,这显然不能作为博客在网上发布;比如它默认的数学公示模块是 mathjax, 而我想用 katex; 再比如代码高亮、mermaid.js 的支持等等。

每个博客发布工具都一样,如果不讲究这些细节的东西,大抵都是能用的;而如果想要自定义,必然要深入到源代码中去客制化。

Org Export

官网中提到,Org Mode 能导出多种形式的文件,包括 html, latex 等,包括之前提到 ox-hugo, 它就是把 org 导出成 md.

整个导出模块是 ox, 即 org export, 它是一个抽象的模块,定义了导出的属性和过程的逻辑,相当于定位了 org 文件中哪些东西需要导出,比如粗体、代码块、表格等。

Org export 不能单独工作,它需要后端去解释怎么导出这个过,这些不同的解释叫作不同的后端 (backend). 如 org-html 是一个后端,它利用 Org Export 提供的抽象函数,完成了 org 导出 html 的过程; org-latex 是一个后端,同样利用底层的抽象函数,完成了 org 导出 latex 的过程。

即,Org Export 定义了单个文件导出的抽象集合,Org Html 或其他后端定义了单个文件导出的具体过程, Org Publish 则定义了一个项目(多个文件)的导出过程。

flowchart TD

A(Org Export) --> B(Org Html) --> C(Org Publish)

Org Export 还有很多前面以及 Org Publish 没有提到的导出过程的属性,可以 M-x describe-varialbe RET org-export-options-alist 查看。导出过程中,这些属性都可以在 plist/info 中找到。

发布过程链

Org Publish (ox-publish.el):

  • org-publish: 选择项目,开始发布
  • org-publish-projects: 逐个发布项目,获取项目中需要发布的文件,同时进行增量检查,最终只发布未创建或这修改过的文件
  • org-publish-file: 发布项目中的文件,调用 publishing-function 去实现文件的发布(以默认的 org-html-publish-to-html 未例

Org Html (ox-html.el):

  • org-html-publish-to-html: 回调 org-publish-org-to, 传入 html 后端,表明了整个导出模块在导出 html 的时候要调用哪些函数
  • org-publish-org-to: 打开源文件的 buffer, 读取内容,传入 org-export-to-file 中进行导出

Org Export (ox.el):

  • org-export-to-file: 打开目标文件的 buffer, 将 html 传入 org-export-as 中生成导出的 html 字符串,写入到 buffer 中并保存
  • org-export-as: 分析源文件的内容,根据给定后端,对应导出到新格式的内容

自定义后端

使用 org-export-define-derived-backend 来派生出一个后端。

(org-export-define-derived-backend 'blorg-backend 'html
  :translate-alist '((template . blorg-html-template)
                     (inner-template . blorg-html-inner-template)
                     (src-block . blorg-html-src-block)
                     (link . blorg-html-link)
                     (special-block . blorg-html-special-block)
                     (quote-block . blorg-html-quote-block)
                     (footnote-reference . blorg-html-footnote-reference)
                     (headline . blorg-html-headline))
  :filters-alist '((:filter-final-output . (org-html-final-function
                                            blorg-html-remove-spaces))))

这里定义了一个后端叫 blorg-backend, 它基于 html 后端,将其中对应的函数进行修改。 translate-alist 是一个映射,指定了各种形式的内容用哪个函数进行导出。

template 是整个 HTML 模板,从 <html> 出发,包括了 <head><body>. 传入的参数中 contents 是调用了 inner-template 之后的得到的字符串。而 inner-template 则是是文章导出后的 HTML,也就是它是从底层开始导出,最后再在上层拼装。

同样的道理,对于代码块 (src-block) 、超链接 (link) 等都可以用自定义的函数去操作。

具体有哪模块和参数,可以去看 ox-html.el (describe 与 org-html 相关的函数就能找到)在开头使用 org-export-define-backend 对 html 后端进行的定义。

自定义后端之后,我们就可以魔改 org-publish-file, 使用我们自己的后端来执行导出操作。

Tips & Tricks

导出过程中获取源文件名

在 info/plist 中有一个 :input-file 属性,它就是源文件的绝对路径,通过 plist-get 获取即可。

导出的文章调用另一个文章的连接是本地路径

修改后端的 link 导出函数,自定义的 link 函数调用 org-html-link 来获取初始的 <a>, 然后做通过正则匹配 href, 调整后再通过正则替换。参考如下:

(defun blorg-html-link (link desc info)
  "FIX: Links and Images"
  (let ((html-string (org-html-link link desc info)))
    ;; Handle normal links
    (cond
     ((s-starts-with-p "<a" html-string)
      (let ((url-match (s-match "href=\"\\([^\"]+\\)\"" html-string)))
        (if (and url-match (s-starts-with-p "#" (cadr url-match)))
            html-string
          (concat "<a target=\"_blank\"" (substring html-string 2)))))
     ;; Handle image
     ((s-starts-with-p "<img" html-string)
      (let ((url-match (s-match "src=\"\\([^\"]+\\)\"" html-string)))
        (if url-match
            (let* ((link (cadr url-match))
                   (uri (f-join "/" (f-dirname link) (concat (md5 (f-base link))
                                                             "."
                                                             (f-ext link))))
                   (html-string-1 (s-replace-regexp "src=\"\\([^\"]+\\)\""
                                                    (format "src=\"%s\"" uri)
                                                    html-string)))
              (concat (format "<a href=\"%s\" target=\"_blank\">\n" uri)
                      html-string-1
                      "</a>"))
          html-string)))
     (t html-string))))

在自定义后端的 :translate-alist 添加 (link . blorg-html-link).

第三方插件

每个页面导出时,设置一个列表变量 blorg-extra-pkgs. 比如当前文章用到了数学导出,那么在导出数学公式的时候往 blorg-extra-pkgs 里添加一个 math. 因为导出过程是从下到上,所以在最终拼装整个 HTML template 的时候,去索引 blorg-extra-pkgs 里面的符号,比如有 math, 那么就在 HTML 的头和尾加上相应的 css 样式和 js 脚本即可。

怎么知道当前正在导出的是数学公式呢?需要去看 ox-html.el 的源码,查找关键词,去跟一下导出过程。

样式

至于导出之后的 HTML, 怎么配置一个好看的主题呢?最后是去网上找现成的,有一些项目是 Org Publish 的主题,不过可能需要调整。

我是手撸的,之前也是,所以有些经验。这次主题的前端架构大量参考了之前用的 Hugo 的主题 LoveIt.

GitHub Action 自动部署

传统的部署方式是,在本地跑了 org-publish, 整个博客站点在本地生成,然后通过 git 上传到仓库。

通过 GitHub 的 Action, 可以实现一些自动化,比如部署和生成的操作,也就是说,笔记只需要 push 上去,生成博客这个过程在云端那边实现-—本质上就是那边开了个虚拟机来跑部署程序。

去看一下 GitHub Action 文档的 Quick Start, 快速写一个简单的脚本即可。

Action 脚本

在笔记仓库的目录下新建一个文件夹 .github/workflowsworkflows 也是一个文件夹,就是自动化脚本。

脚本是用 YAML 写的,所以新建一个脚本 org-publish.yml, 名字随便。

name: Blorg to export html
on: 
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Set Timezone
        run: sudo timedatectl set-timezone 'Asia/Shanghai'

      - name: Checkout Repo
        uses: actions/checkout@v4

      - name: Setup Emacs
        uses: purcell/setup-emacs@master
        with:
          version: 29.2

      - name: Publish
        run: emacs -Q --batch -nw --load ${{ github.workspace }}/init.el --eval "(blorg t)" --kill

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
          external_repository: fingerknight/blog-site
          publish_branch: main
          publish_dir: ./doc

解释:

  1. name, 顾名思义,就是这个脚本的项目名,不要求和文件名统一
  2. on, 表示触发条件,这里表示 push 之后触发,且限定为 main 分支
  3. jobs, 就是任务列表,它可以跑多个任务,比如这里定义了一个叫作 deploy 的任务
  4. runs-on, 就是设备环境
  5. steps, 该任务的执行步骤,它是一个列表,所以每个任务打头都应该有一个横杠
  6. run, 执行某条命令
  7. uses, 调用外部的某个 action 脚本

具体细节我不了解,想搞清楚参阅官方文档。

脚本的内容是参考这篇文章《用Org Mode + Hugo写博客,并通过Github Action自动部署到Github Pages - superbear's blog》。

actions/checkout

自动部署本质就是它那边的设备跑程序,所以 checkout 就是把当前的仓库下载到虚拟机上。

purcell/setup-emacs@master

人家的虚拟机是不能乱装东西的,所以它没有 Emacs. 根据参考文章,Purcell 大神维护了一个 action 的仓库,就是用来安装 Emacs.

通过 with 来设置版本号。

然后调用命令去执行 Org Publish. 其中 ${{ github.workspace }} 是当前工作区目录。调用前面的 checkout 之后好像自动进入仓库目录,所以打开 Emacs 时直接 load 仓库目录下的 init.el 即可。

(require 'package)
(require 'package-vc)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(package-initialize)

(dolist (pkg '(denote dash f s ht ts))
  (unless (package-installed-p pkg)
    (package-install pkg)))

(unless (package-installed-p 'blorg)
  (package-vc-install "https://github.com/fingerknight/blorg" nil nil 'blorg))

(require 'denote)
(require 'blorg)

(plist-put blorg-base-plist
           :html-head
           (concat "<link rel=\"shortcut icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\">\n"
                   "<link rel=\"stylesheet\" href=\"/static/Spectral/style.css\">\n"
                   "<link rel=\"stylesheet\" href=\"/static/LXGWWenKai/style.css\">\n"
                   "<link rel=\"stylesheet\" href=\"/static/style.css\">\n"))

(setq blorg-html-extra-head
      `((mermaid . "")
        (math . "<link rel=\"stylesheet\" href=\"/static/katex/katex.min.css\">\n")
        (highlight . ,(concat "<link rel=\"stylesheet\" href=\"/static/FantasqueSansMono/style.css\"/>\n"
                              "<link rel=\"stylesheet\" href=\"/static/prism/style.css\">\n")))

      blorg-html-extra-foot
      `((mermaid . ,(concat "<script defer src=\"/static/mermaid/mermaid.min.js\"></script>"
                            "<script src=\"/static/mermaid/script.js\"></script>"))
        (math . ,(concat "<script defer src=\"/static/katex/katex.min.js\"></script>\n"
                         "<script defer src=\"/static/katex/auto-render.min.js\"></script>\n"
                         "<script src=\"/static/katex/script.js\"></script>"))
        (highlight . "<script src=\"/static/prism/script.js\"></script>\n")))

(setq denote-directory "/home/runner/work/Note/Note"
      denote-excluded-directories-regexp "^assets\\|^static\\|^doc"
      blorg-output-dir "/home/runner/work/Note/Note/doc")

(provide 'init)

我的仓库名字叫 Note, 这里面两个 Note, 前一个是根据仓库建立的家目录,后面的是 checkout 的文件名(我猜)。具体是哪个,可以写个测试脚本 -run: ls ${{ github.workspace }} ,然后去仓库 Action 标签那里看结果,会显示完整命令。

我用 setup-emacs 会有个 bug, 直接使用会出现报错 emacs: standard input is not a tty. 查看 Issue, 要加 --batch 参数解决。

peaceiris/actions-gh-pages@v3

将生成的博客,部署到另外一个仓库。理论上也可以上传到本地仓库(会不会出现 pull 触发导致循环呢?),但是两个仓库,笔记仓库不公开,仅自己可见;博客仓库公开,所有人可见,这诚然是一种好策略。

部署有四个重要的选项:

  • deploy_key: 这个固定写 ${{ secrets.ACTIONS_DEPLOY_KEY }} ,官方文档里会说怎么配置,我下面也会记录
  • external_repository: 要部署到的仓库,也就是公开的博客的仓库
  • publish_branch: 要部署到的分支
  • publish_dir: 当前博客在本地的地址,因为当前目录就是笔记仓库的目录,所以直接 ./ 加目录就好了

Deploy Key 的配置

本地生成密钥对: ssh-keygen -t rsa -b 4096 -C "$(git config user.email)" -f gh-pages -N "" ,在当前目录下会得到公钥 gh-pages.pub 和私钥 gh-pages.

将公钥添加到部署的仓库,也就是公开的博客仓库;将私钥添加到私密的笔记仓库。

在博客仓库下, Settings -> Depoly keys -> add deploy key, 名字随意,将公钥复制进去。

在笔记仓库下, Settings -> Secrets and variables -> Actions -> New repository secret, 名字必须是 ACTIONS_DEPLOY_KEY, 将私钥复制进去。

自动化部署

至此,都 push 笔记之后,action 就会重建博客,根据 peaceiris/actions-gh-pages 的说明,它默认会把目标仓库先清空,在重新生成,可以通过加 keep_files: true 来保存原有文件,通过覆盖放方式进行部署。所以其实用了 GitHub Action 之后就不需要考虑增量部署,反正压力都在云端,我本地无所谓,最多也就是一分钟不到就搞定了。

Cloudflare Pages

至此,可以通过 GitHub Page 来配置仓库,博客也就搞定了。但是听说 Cloudflare Page 要快一点,刚好手上有个域名,所以就是试试。

Workers & Pages 那里 create application. 选择 Pages, 连接到 Github 仓库。这里连接的是部署好的博客的仓库,随后的各种部署命令都默认,也就是不需要在这里部署, GitHub Action 那里已经把博客生成了,Cloudflare 只需要把页面接管过来即可。

配置好后它会免费提供一个域名,访问应该就能看到来。当然也可以自定义域名,需要先在 Cloudflare 配好域名:注册一个新 Website, 根据它给的说明,在域名提供商那边配置 Cloudflare 的 Name Server.

具体速度吧,感觉上快上一点点,不过半斤八两吧。

Footnotes:

(1)

SASS 是一个 css 的扩展语言,用于简化 css 的编码过程。见 Sass: Syntactically Awesome Style Sheets.

Powered by Org Mode.