Blogging with Org Mode and Denote
It’s that time of a year, you want to “reboot” your blog site again, instead of actually writing something, you prefer to build the site in a completely new way instead, or it’s just me? Never mind then.
To Simplicity
My view of what makes a beautiful blog has changed over time, but always circles back to simplicity. I used to build my blog with Next.js with server site rendering — already fairly minimal with limited JavaScript—but now I question whether I even need a framework. While I still enjoy working with React, it seems an overkill for a blog site. And sourcehut and bearblog shows how elegant true minimalism can be.
The Plan
I’m already using org-publish to generate the posts last time, and I know that org-publish is capable of generating fully featured websites. It’s an obvious choice for me.
However, since my last blog post, I made a lot of changes with my note workflow, notably, I have switched my note taking system from org-roam to denote, and all my blog posts are still in the roam collection, so I need to take care of the migration too.
So the overall plan looks like this:
- make a org-publish project that can generate
- pages: index page, about page etc
- posts: all past and future blog posts
- rss: I hecking love rss
- styles: custom css styles
- migrate all past notes from roam to denote
- tweak the styles until I feel good about it
Time to dive in!
Org Publish
It’s easier than I thought, there’s ton of good tutorials as well as packages out there for using org-publish for blogging, I cherry picked some of their snippets and come up with my own.
First I have some helper functions for creating new blog post as well as publishing it.
(defun denote-blog-post () "Create a blog entry in denote sub directory." (declare (interactive-only t)) (interactive) (let ((denote-use-directory (expand-file-name "blog/posts" denote-directory))) (call-interactively 'denote)))
And there is some variables used in the project definitions, here I choose to make the publish directory a git project too, so I still remain some control on the final publish, in case I messed up the publish flow later.
(defvar m/blog-publishing-directory (expand-file-name "~/projs/blog-publish/public")) (defvar m/blog-header "<header> <a class=\"title\" href=\"/\"> <h2>Uncharted Mind Space</h2> </a> <nav> <a href=\"/\">Home</a> <a href=\"/about\">About</a> </nav> </header>") (defvar m/blog-footer "<footer> <p>Powered by <a href=\"https://orgmode.org/\">Org Mode</a></p> </footer>") (defvar m/blog-meta "<link rel=\"stylesheet\" type=\"text/css\" href=\"static/style.css\"/>")
And finally the org publish projects setup.
(setq org-publish-project-alist `(("blog" :components ("blog-posts" "blog-static")) ("blog-posts" :base-directory ,(expand-file-name "blog" denote-directory) :base-extension "org" :recursive t :publishing-function org-html-publish-to-html :exclude ,(regexp-opt '("draft" "template" "sitemap")) :publishing-directory ,(expand-file-name "" m/blog-publishing-directory) :html-head ,m/blog-meta :html-preamble ,m/blog-header :html-postamble ,m/blog-footer :html-link-org-files-as-html t :auto-rss t :rss-title "Uncharted Mind Space" ;; TODO: use a variable :rss-description "Hello" :rss-link "https://merrick.luois.me" :rss-filter-function m/blog-filter-rss :rss-with-content all :completion-function org-publish-rss) ("blog-static" :base-directory ,(expand-file-name "blog/static" denote-directory) :base-extension ,(regexp-opt '("css" "png" "svg")) :recursive t :publishing-directory ,(expand-file-name "static" m/blog-publishing-directory) :publishing-function org-publish-attachment)))
You’ll notice there is some :rss-* keys in the blog-posts project, which comes with my choice of rss generation package: org-publish-rss. It almost works out of the box, with 1 major problem: it doesn’t work with the export_file_name
property, so I had to fork it and make a workaround, until I figure out how to properly fix it and submit a patch.
(use-package! org-publish-rss :config (defun m/gen-rss-link-name (file) (concat "posts/" (org-export-file-name file))) (setq org-publish-rss-publish-immediately t) (setq org-publish-rss-link-method #'m/gen-rss-link-name)) (defun m/blog-filter-rss (path) (not (string-match-p (regexp-opt '("index" "template" "page")) path)))
The same issue happened when exporting denote links, but it was simple enough to use an advice to fix it, I’ve also reported the issue to denote, hopefully it can be fixed soon.
(defun org-export-file-name (path) (with-temp-buffer (insert-file-contents path) (org-mode) (org-export-output-file-name ""))) (defun org-export-file-path (path) "Return the export file path for PATH." (let ((dir (file-name-directory path)) (name (org-export-file-name path))) (expand-file-name name dir))) (defun m/denote-link-ol-export (link description format) "Export a `denote:' link from Org files. The LINK, DESCRIPTION, and FORMAT are handled by the export backend." (pcase-let* ((`(,path ,query ,file-search) (denote-link--ol-resolve-link-to-target link :full-data)) (anchor (when path (file-relative-name (org-export-file-path path)))) ;;(anchor (when path (file-relative-name (file-name-sans-extension path)))) (desc (cond (description) (file-search (format "denote:%s::%s" query file-search)) (t (concat "denote:" query))))) (if path (pcase format ('html (if file-search (format "<a href=\"%s.html%s\">%s</a>" anchor file-search desc) (format "<a href=\"%s.html\">%s</a>" anchor desc))) ('latex (format "\\href{%s}{%s}" (replace-regexp-in-string "[\\{}$%&_#~^]" "\\\\\\&" path) desc)) ('texinfo (format "@uref{%s,%s}" path desc)) ('ascii (format "[%s] <denote:%s>" desc path)) ('md (format "[%s](%s)" desc path)) (_ path)) (format-message "[[Denote query for `%s']]" query)))) (advice-add 'denote-link-ol-export :override #'m/denote-link-ol-export)
And it’s done! I won’t say it’s effortless, but it feels really good when you see the site working.
Styling
The css styles is mostly inspired by shamelessly taken from bearblog, with some minor tweaks. You can easily find it in the site’s source.
Migrate Posts
With a simple search, I discovered this handy tool: nm-org-roam-to-denote.el. Since it’s a one-time use script, I simply copied it to my scratch buffer and evaluated it. To keep things organized, I limited the scope to just my blog posts subdirectory. After the migration, I did some manual adjustments to optimize the front matter for Denote, then used `denote-rename-file-using-front-matter` to properly rename all the files.
Now publish the project again and.. Oops, the date is completely wrong compare to the original site, some more manual fix again, luckily I only have 10+ posts anyway.
Open Graph Protocol
There is shocking lack of resource online for doing this in org-publish, so I had to make my own, it’s set to a global option org-html-meta-tags
which is not ideal but I’m not using org-publish for other stuff yet, so it’s acceptable for now.
(after! (ox-publish org-publish-rss) (defun m/org-element-plain-text (info key) (org-html-plain-text (org-element-interpret-data (plist-get info key)) info)) (defun m/blog-opengraph-meta(info) "Build Open Graph meta fields for html." (append (org-html-meta-tags-default info) (let* ((site-name "Uncharted Mind Space") (parsed-tree (plist-get info :parse-tree)) (first-heading (org-element-map parsed-tree 'headline #'identity nil t)) (section-content (org-element-property :contents-begin first-heading)) (raw-content (plist-get info :input-buffer)) (description (when section-content (let ((text (with-current-buffer raw-content (buffer-substring-no-properties section-content (min (+ section-content 100) (buffer-size)))))) (if (> (length text) 97) (concat (substring text 0 97) "...") text))))) `(("name" "description" ,(or description "")) ("property" "og:locale" ,(m/org-element-plain-text info :language)) ("property" "og:title" ,(m/org-element-plain-text info :title)) ("property" "og:type" "article") ("property" "og:description" ,(or description "")) ("property" "og:site_name" ,site-name))))) (setq org-html-meta-tags #'m/blog-opengraph-meta)
Closing Thoughts
This new blogging setup feels just right for me. Did you notice all the code blocks in this post have syntax highlighting? And the best part - no custom CSS or JavaScript required! It even uses your Emacs theme when exporting.
Here it is: a clean, fast blog with minimal CSS, completely generated from Org files. And I’m happy with it.
Updates
- add Denote to title