基于 org-mode 项目发布功能(ox-publish)的静态站点生成框架

前传

我之前想彻底转到 org-mode 用来生成网站,理由有三:

  1. 自己做的可以吹;
  2. org 功能比较强;
  3. 之后比较方便和 emacs 生态对接。

还有一个比较私人的原因是想重新整顿一下网站。

在看到 这个网站 后更按耐不住了。这个网站有 3 栏,不仅把个人 wiki 放了上去,还把每周日志也收录了进去——如果所有工作都由 org 管理的话,可以导出美观能看的个人总结。这个网站的框架 Pile 其实写得不错,但是有一些小毛病没跑起来。

有人在 issue 里问怎么用 pile,作者的意思怎么读怎么像:我不想替你维护,你自己写一套吧。于是(大概在5月份的时候)我就抄了一下,自己写了一个比较简单的框架。一直以来没发布也没怎么用,觉得可能在论坛里记录一下比较好(免得忘了Orz),也是抛砖引玉,希望能吸引到大牛来做一个更好用的框架。

原理

Org 用户应该无人不知导出功能。Org 的发布功能(ox-publish)正是利用这个机能,一次性打包导出一堆 org 文件和静态资源文件。发布的核心入口是 org-publish-project-alist。最最基本的用法是这样的:

(setq org-publish-project-alist
      '(("pages"
         :base-directory "~/org"
         :base-extension "org"
         :publishing-directory "/var/www/html"
         :publishing-function org-html-publish-to-html
         :recursive t)))

该配置的意思很明显了,就是对 ~/org 下的所有 org 文件执行 org-html-publish-to-html 再把生成的 html 放到 /var/www/html 里去。这里能用的关键字参数(不管是 publish 自己的还是 export 的)非常多,可以参考文档 (info "(org) Publishing options").

看起来很简单?确实如此,但是要注意到,这里没有发布静态数据文件(包括但不限于图片、CSS 和 JS)。此外,如果要维护多个 project,常常需要把相同的配置写好几遍。

接下来要做的,就是把这个接口封装得好用一点。

项目类与配置生成

org-publish-project-alist 可以控制 publish 过程的方方面面,缺陷就是要把所有 project 写到一起,而且非常冗余,不怎么 scalable。第一步就是定义一个基类和生成配置的接口,从而

  1. 配置好看一点;
  2. 允许不同类型的 project 自定义配置生成过程和具体的生成参数。
(defclass k/project! ()
  ((name :initarg :name
         :type string)
   (url :initarg :url
        :type string)
   (css :initarg :css
        :type string
        :initform "/css/main.css")
   (input-dir :initarg :input-dir
              :type string)
   (output-dir :initarg :output-dir
               :type string)
   (language :initarg :language
             :type string
             :initform "zh-CN")
   (preamble :initarg :preamble
             :initform "")
   (postamble :initarg :postamble
              :initform t))
  :abstract t)

(cl-defgeneric k/static-config ((pj k/project!))
  (list (format "k-%s-static" (oref pj :name))
        :hidden t
        :base-directory (oref pj :input-dir)
        :base-extension ".*"
        :exclude ".*\\.org$"
        :publishing-function #'org-publish-attachment
        :publishing-directory (oref pj :output-dir)
        :recursive t))

(cl-defgeneric k/pages-config ((pj k/project!))
  (list (format "k-%s-pages" (oref pj :name))
        :hidden t
        :base-directory (oref pj :input-dir)
        :base-extension "org"
        :exclude (regexp-opt '("feed.org"))
        :publishing-directory (oref pj :output-dir)
        :publishing-function #'org-html-publish-to-html
        :recursive t
        :language (oref pj :language)
        :html-head (format "<link rel=\"stylesheet\" type=\"text/css\" href=\"%s\" />"
                           (oref pj :css))
        :htmlized-source nil
        :html-doctype "html5"
        :html-checkbox-type 'unicode
        :html-html5-fancy t
        :html-postamble (oref pj :postamble)
        :html-preamble (oref pj :preamble)
        :html-klipsify-src t))

(cl-defmethod k/configs ((pj k/project!))
  (list (k/static-config pj)
        (k/pages-config pj)
        (let ((name (oref pj :name)))
          (list (format "k-%s" name)
                :components (list (format "k-%s-static" name)
                                  (format "k-%s-pages" name))))))

(defvar k/projects nil)

(defun k/setup! ()
  (dolist (pj k/projects)
    (setq org-publish-project-alist
          (append org-publish-project-alist (k/configs pj)))))

由此,用户只需要通过创建合适的 k/project! 对象放到 k/projects 里面,再调用一下 (k/setup!) 一切就 OK 了。接下来只需要探讨一下怎么具体实现配置生成。

注:这里的 generic 提供了默认实现。

静态文件项目

此类项目只需要用 org-publish-attachment 把所有 :input-dir 下的静态文件全部发送过去就可以了。所以起名叫 k/send!

(defclass k/send! (k/project!)
  ((extension :initarg :extension
              :initform ".*"
              :type string))
  :documentation "Send the files from INPUT-DIR to OUTPUT-DIR as-is.")

(cl-defmethod k/configs ((pj k/send!))
  (list (list (format "%s" (oref pj :name))
              :base-directory (oref pj :input-dir)
              :base-extension (oref pj :extension)
              :publishing-directory (oref pj :output-dir)
              :publishing-function #'org-publish-attachment)))

单页面项目

单页面项目的布局是一个目录下有一个名叫 index.org 的文件,这个目录下面包括这个 org 文件使用到的其他数据。不难发现默认实现就满足这个需求,所以什么都不用做。

(defclass k/page! (k/project!) ()
  :documentation "A single page (a directory containing index.org and other static files)")

项目分组

org-publish-project-alist 支持把若干个项目打包到一起(使用 :components 参数)。

(defclass k/group! (k/project!)
  ((components :initarg :components)))

(cl-defmethod k/configs ((pj k/group!))
  (list (list (format "%s" (oref pj :name))
              :components (oref pj :components))))

博客项目

接下来是最复杂的博客。一个正常人的博客应该至少有以下功能:

  1. 内容页面:每篇博客的 org 文件可以被发布出来,页面引用的静态文件也可以被发布。
  2. 博文列表:首页。最差的情况也是类似王垠的博客那样的。
  3. RSS生成。
  4. 评论。

k/blog! 类定义

(defclass k/blog! (k/project!)
  ((rss? :initarg :rss?
         :type boolean
         :initform t)
   (archive? :initarg :archive?
             :type boolean
             :initform t)))

这里的 :rss?:archive? 参数用于控制是否生成 RSS feed 和首页(这里叫 archive,文件名必须是 index.org)。

注:如果不需要 RSS 和 Archive,默认的接口实现中的 :recursive t 已经可以实现导出功能了。

首页生成

首页生成利用了 ox-publish 的 auto-sitemap 功能。

(cl-defmethod k/pages-config ((pj k/blog!))
  (let ((base-config (cl-call-next-method pj)))
    (when (oref pj :archive?)
      (setq base-config
            (append base-config
                    (list :auto-sitemap t
                          :sitemap-filename "index.org"
                          :sitemap-title (oref pj :name)
                          :sitemap-style 'list
                          :sitemap-sort-files 'anti-chronologically
                          :sitemap-function 'k/blog-format-archive
                          :sitemap-format-entry 'k/blog-format-archive-entry))))
    base-config))

(defun k/blog-format-archive (title list)
  (concat "#+TITLE: " title "\n\n#+ATTR_HTML: :class blog-posts\n"
	  (org-list-to-org list)))

(defun k/blog-format-archive-entry (entry style project)
  (cond ((not (directory-name-p entry))
         (format "*[[file:%s][%s]]*
                 #+HTML: <p class='pubdate'>%s</p>"
                 entry
                 (org-publish-find-title entry project)
                 (format-time-string k/date-format
                                     (org-publish-find-date entry project))))
        ((eq style 'tree) (file-name-nondirectory (directory-file-name entry)))
        (t entry)))

在每次 publish 的时候,sitemap 功能会将每个文件传递到 :sitemap-format-entry 函数里,产生一个列表,最后把整个列表传递到 :sitemap-function 产生 index.org 文件。也就是每次 publish 的时候都会重新产生一次 sitemap。

RSS 生成

RSS 生成则复杂得多。尽管 Org 有 RSS exporter,但这个功能只能用于单个文件里的 headlines!我借鉴了一篇文章的做法,使用 auto-sitemap 功能来“收集” RSS entry,最后生成一个 feed.org,这个文件最后会传递给 RSS exporter 产生 RSS feed。为了做这件事,不得不引入一个新的 org publish project。

(cl-defmethod k/rss-config ((pj k/blog!))
  (list (format "k-%s-rss" (oref pj :name))
        :base-directory (oref pj :input-dir)
        :base-extension "org"
        :recursive t
        :exclude (regexp-opt '("index.org" "404.org" "feed.org"))
        :publishing-function 'k/blog-rss-publish
        :publishing-directory (oref pj :output-dir)
        :language (oref pj :language)
        :html-link-home (oref pj :url)
        :rss-link-home (oref pj :url)
        :html-link-use-abs-url t
        :auto-sitemap t
        :sitemap-filename "feed.org"
        :sitemap-title (oref pj :name)
        :sitemap-style 'list
        :sitemap-sort-files 'anti-chronologically
        :sitemap-function 'k/blog-format-rss
        :sitemap-format-entry 'k/blog-format-rss-entry))

(defun k/blog-rss-publish (plist filename pub-dir)
  (if (string= "feed.org" (file-name-nondirectory filename))
      (org-rss-publish-to-rss plist filename pub-dir)))

(defun k/blog-format-rss (title list)
  (concat "#+TITLE: " title "\n\n"
          (org-list-to-subtree list 0 '(:icount "" :istart ""))))

(defun k/blog-format-rss-entry (entry style project)
  (cond ((not (directory-name-p entry))
         (let* ((file (org-publish--expand-file-name entry project))
                (title (org-publish-find-title entry project))
                (date (format-time-string "%Y-%m-%d" (org-publish-find-date entry project)))
                (link (concat (file-name-sans-extension entry) ".html")))
           (with-temp-buffer
             (insert (format "* [[file:%s][%s]]\n" file title))
             (org-set-property "RSS_TITLE" title)
             (org-set-property "RSS_PERMALINK" link)
             (org-set-property "PUBDATE" date)
             (insert-file-contents file) ;; BUG??
             (buffer-string))))
        ((eq style 'tree)
         ;; Return only last subdir.
         (file-name-nondirectory (directory-file-name entry)))
        (t entry)))

(怎么说呢…… 因为要 RSS,blog 的 config 直接翻倍了…… 从这里你应该已经有不好的预感了……)

最后,如果 blog project 的 :rss? 是 non-nil,就把 RSS config 加入到 blog 的 config 集合里面:

(cl-defmethod k/configs ((pj k/blog!))
  (let ((base-configs (cl-call-next-method pj)))
    (if (oref pj :rss?)
        (progn
          (push (k/rss-config pj) base-configs)
          (let* ((name (oref pj :name))
                 (components (caddr (assoc (format "k-%s" name) base-configs))))
            (push (format "k-%s-rss" name) components)
            (setf (caddr (assoc (format "k-%s" name) base-configs))
                  components))
          base-configs)
      base-configs)))

导航条?

这时候你会发现上面的配置里面完全没有导航条的事。尽管 Org 支持 HTML preamble,但是…… 好吧,很难受。我现在的做法是这样的:

(defun k/site-nav (active)
  "Generate a navbar."
  (format
   "<header>
     <div class=\"site-title\">
       <a href=\"/\">
         <img src=\"/avatar.jpg\">
       </a>
     </div>
     <div class=\"site-nav\">
       <a%s href=\"/blog\"> blog</a>
       <a%s href=\"/journal\"> journal</a>
       <a%s href=\"/koishi\"> koishi</a>
       <a%s href=\"/wiki\"> wiki</a>
       <a%s href=\"/about\"> about</a>
     </div>
     <div class=\"clearfix\"></div>
   </header>"
   (if (eq active 'blog) " class=\"active\"" "")
   (if (eq active 'journal) " class=\"active\"" "")
   (if (eq active 'koishi) " class=\"active\"" "")
   (if (eq active 'wiki) " class=\"active\"" "")
   (if (eq active 'about) " class=\"active\"" "")))

创建 project 的时候把 preamble 传进去:

(k/page! :name "foo"
                     :url "https://example.com/about"
                     :input-dir (concat input-dir "/about")
                     :output-dir (concat output-dir "/about")
                     :preamble #'(lambda (entry) (k/site-nav 'about)))

非常恶心,但是如果闭着眼睛不看代码的话,就没事了……

Wiki项目

这里的 wiki 指一系列互相链接的 org 文件,导出简单一些。

(defclass k/wiki! (k/project!) ()
  :documentation "Wiki project type.")

(cl-defmethod k/pages-config ((pj k/wiki!))
  (let ((base-config (cl-call-next-method pj)))
    (cons (car base-config)
          (append (list :auto-sitemap t
                        :sitemap-title (oref pj :name)
                        :sitemap-filename "index.org"
                        :sitemap-style 'tree)
                  (cdr base-config)))))

其他

:hidden 参数

上面的配置里面用到了 :hidden 参数。这个参数不是 ox-publish 自己的,会被忽略。我加这个参数是因为一个 project 会产生最多 3 个 org publish project,有点眼花缭乱的感觉,所以自己加了 advice 忽略掉这些 project。

(defun org-publish-project-alist-without-hidden ()
  (remove-if #'(lambda (project) (plist-get (cdr project) :hidden))
             org-publish-project-alist))

(define-advice org-publish (:override (project &optional force async))
  "Don't display projects marked with :hidden on the list."
  (interactive
   (list (assoc (completing-read "Publish project: "
				 (org-publish-project-alist-without-hidden) nil t)
		org-publish-project-alist)
	 current-prefix-arg))
  (let ((project (if (not (stringp project)) project
		   ;; If this function is called in batch mode,
		   ;; PROJECT is still a string here.
		   (assoc project org-publish-project-alist))))
    (cond
     ((not project))
     (async
      (org-export-async-start (lambda (_) nil)
	`(let ((org-publish-use-timestamps-flag
		,(and (not force) org-publish-use-timestamps-flag)))
	   ;; Expand components right now as external process may not
	   ;; be aware of complete `org-publish-project-alist'.
	   (org-publish-projects
	    ',(org-publish-expand-projects (list project))))))
     (t (save-window-excursion
	  (let ((org-publish-use-timestamps-flag
		 (and (not force) org-publish-use-timestamps-flag)))
	    (org-publish-projects (list project))))))))

写博客命令

这个就比较直观了。

(defun k/blog-projects ()
  (remove-if-not #'k/blog!-p k/projects))

(defun k/find-project (name)
  (catch 'answer
    (dolist (pj k/projects)
      (when (string= (oref pj :name) name)
        (throw 'answer pj)))))

(defun k/choose-project (prompt type)
  (let* ((projects (remove-if-not (intern (format "k/%s!-p" type)) k/projects))
         (name (completing-read prompt
                                (mapcar (lambda (obj) (oref obj :name))
                                        projects)
                                nil t)))
    (k/find-project name)))

(defun k/blog-new-post ()
  (interactive)
  (cl-destructuring-bind (_ _ _ day month year _ _ _) (decode-time)
    (let* ((pj (k/choose-project "Blog: " 'blog))
           (permalink (read-string "Permalink: "))
           (post-dir (f-join (oref pj :input-dir)
                             (number-to-string year)
                             (format "%02d" month)
                             (format "%02d" day)
                             permalink))
           (index-file (f-join post-dir "index.org")))
      (make-directory post-dir t)
      (find-file index-file))))

半自动地把 Markdown 转换成 Org

转换过程中逐渐写了一堆半自动转换规则,省了很多力气,所以当时把 md 转换到 org 也没花很多时间。

(defun md2org ()
  "Convert the current buffer (Markdown) to Org format.

This is a best-effort convertion for Jekyll posts.  Please review
and make changes afterwards."
  (interactive)
  (replace-regexp "^---\nlayout:.*\n" "" nil (point-min) (point-max))
  (replace-regexp "^title: \"\\(.*?\\)\"" "#+title: \\1" nil (point-min) (point-max))
  (replace-regexp "^date: \\(.*?\\)$" "#+date: \\1" nil (point-min) (point-max))
  (replace-regexp "^---" "" nil (point-min) (point-max))
  (replace-regexp "<!-- *more *-->" "" nil (point-min) (point-max))
  (replace-regexp "^{% *endhighlight *%}" "#+end_src" nil (point-min) (point-max))
  (goto-char (point-min))
  (replace-regexp "{% *highlight +\\(.*?\\) *%}" "\n#+begin_src \\1" nil (point-min) (point-max))
  (setq b t)
  (while (re-search-forward "^``` ?\\(.*?\\)? *$" nil t)
    (if b
        (progn (setq b nil)
               (if (not (string= (match-string 1) ""))
                   (replace-match "#+begin_src \\1")
                 (replace-match "#+begin_src fundamental")))
      (setq b t)
      (replace-match "#+end_src")))
  (replace-regexp "^``` *$"
                  "\\,(if b (progn (setq b nil) \"#+begin_src fundamental\") (setq b t) \"#+end_src\")"
                  nil (point-min) (point-max))
  (replace-regexp "^* " "- " nil (point-min) (point-max))
  (replace-regexp "^### " "*** " nil (point-min) (point-max))
  (replace-regexp "^## " "** " nil (point-min) (point-max))
  (replace-regexp "^# " "* " nil (point-min) (point-max))
  (replace-regexp "\\[\\(.*?\\)\\](\\(.*?\\))" "[[\\2][\\1]]" nil (point-min) (point-max))
  (goto-char (point-min))
  (while (re-search-forward "\\cc`" nil t)
    (forward-char -1)
    (insert " "))
  (goto-char (point-min))
  (while (re-search-forward "`\\cc" nil t)
    (forward-char -1)
    (insert " "))
  (replace-regexp "`" "=" nil (point-min) (point-max)))

缺陷

目前用下来我发现有这样一些严重的缺陷,而且我目前都还没解决:

  1. 没有一个正经的模版引擎,很不容易自定义页面。而且对生成过程控制不足,无法在 format function 里知道当前页面最终会是什么 URL,难以使用 Disqus 等第三方评论控件。
  2. RSS 生成不可靠。调试发现尽管 format entry function 都调用了,但最终生成的 feed.org 是空的。
  3. 速度慢,不解释。

后记

前两个缺陷让我非常痛苦,调试 elisp 也很难受,所以我又打算转回 Jekyll 了(或者别的什么 SSG)。在叛逃前,把自己之前花了不少时间写的配置发出来吧……

对了,有一个有趣的小东西,不知道各位知道吗?Org HTML Exporter 可以调用 Klipse.js 在用户浏览器里直接运行代码。我发现这个功能在展示一些小算法的时候很有用,读者可以直接修改代码或者参数来看运行效果。

4赞

这楼用来放一些补充资料。

目前完整的代码可以在这里看到,不过上面的正文已经完整覆盖了全部内容,还增加了解释,所以没必要去看了。

参考资料

其实markdown, jekyll,自定义CSS, 这三样就可以完全满足博客编写和任意页面美化需求了。

org-mode写博客太重了,博客内容一般都很简洁。

主要是众口难调,一番折腾后,我就用了

然后到处抄了点css样式

我不知道你说的模板我意思有没有理解对,但是Org Mode可以 #+INCLUDE 这个应该可以用作模板吧