前传
我之前想彻底转到 org-mode 用来生成网站,理由有三:
- 自己做的可以吹;
- org 功能比较强;
- 之后比较方便和 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。第一步就是定义一个基类和生成配置的接口,从而
- 配置好看一点;
- 允许不同类型的 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))))
博客项目
接下来是最复杂的博客。一个正常人的博客应该至少有以下功能:
- 内容页面:每篇博客的 org 文件可以被发布出来,页面引用的静态文件也可以被发布。
- 博文列表:首页。最差的情况也是类似王垠的博客那样的。
- RSS生成。
- 评论。
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)))
缺陷
目前用下来我发现有这样一些严重的缺陷,而且我目前都还没解决:
- 没有一个正经的模版引擎,很不容易自定义页面。而且对生成过程控制不足,无法在 format function 里知道当前页面最终会是什么 URL,难以使用 Disqus 等第三方评论控件。
- RSS 生成不可靠。调试发现尽管 format entry function 都调用了,但最终生成的 feed.org 是空的。
- 速度慢,不解释。
后记
前两个缺陷让我非常痛苦,调试 elisp 也很难受,所以我又打算转回 Jekyll 了(或者别的什么 SSG)。在叛逃前,把自己之前花了不少时间写的配置发出来吧……
对了,有一个有趣的小东西,不知道各位知道吗?Org HTML Exporter 可以调用 Klipse.js 在用户浏览器里直接运行代码。我发现这个功能在展示一些小算法的时候很有用,读者可以直接修改代码或者参数来看运行效果。