Automatic Blog Indexing
Since I migrated this website to use Emacs Org Mode in 2024, I've manually maintained the /blog
page which references my blog posts. On the rare occasions I would publish a new post, I would have to add a new entry to my table of posts. While updating the page is not a particularly time-consuming action, it is an inconvenience that can be removed from the process.
As a background, I used the ox-publish
package to generate HTML from the Org files that constitute the contents of my site. Org files are known for their capabilities in literate programming – a mode of combining commentary and code in a single file. Think a Python Jupyter notebook, but accommodating other programming languages.
Since Org is tightly coupled with Emacs Lisp – the configuration language of Emacs, my text editor – it seems natural that we can use Emacs Lisp to programmatically produce the table of blog posts for us.
So, our goal is to write Emacs Lisp code from within an Org code block that, during the generation of HTML files, will execute and consequently produce an Org table of our blog posts. blog.org
sits in the same directory as blog/
, which contains the blog posts. First, we start by collecting all of those blog posts.
(let* ((blog-dir (expand-file-name "blog/" default-directory)) (org-files (directory-files blog-dir t "\\.org$")) ...) ...)
Next, we want to extract metadata from each blog post. The relevant metadata that we need includes the title and date the post was published. Org has functions for extracting those, so we'll use it. Let's write a function that, given an Org file, extracts the metadata. Since there's multiple fields we want to collect, let's put the values in a fixed-size list: (list date title filename)
.
(defun site/extract-blog-metadata (file) "Extract title and date from org FILE." (save-excursion (find-file file) (let* ((keywords (org-collect-keywords '("TITLE" "DATE"))) (title (cdr (assoc "TITLE" keywords))) (date (cdr (assoc "DATE" keywords)))) (when (and title date) (list (car date) (car title) (file-name-nondirectory file))))))
Next, we can take the org-files
we've collected and collect the metadata of each one. In case a post has missing metadata, let's kick it out. Then, we want to sort the posts based on the date they were published.
(let* ((blog-dir (expand-file-name "blog/" default-directory)) (org-files (directory-files blog-dir t "\\.org$")) (posts (delq nil (mapcar #'site/extract-blog-metadata org-files))) (sorted-posts (sort posts (lambda (a b) (string> (car a) (car b)))))) ...)
For each post, we have its metadata which we can then format into a table entry. Let's write a function to do that, formatting it into a string output representing a row in an Org table. We can use dash.el just to conveniently provide list extraction functions.
(require 'dash) (defun site/format-post-entry (post) "Generate a single entry for a post in the table." (let ((date (-first-item post)) (title (-second-item post)) (filename (-third-item post))) (format " | %s | [[file:blog/%s][%s]] |" date filename title)))
So, we can finally collect our work into a function that generates the blog table:
(defun site/generate-blog-table () "Generate blog post table sorted by date." (let* ((blog-dir (expand-file-name "blog/" default-directory)) (org-files (directory-files blog-dir t "\\.org$")) (posts (delq nil (mapcar #'site/extract-blog-metadata org-files))) (sorted-posts (sort posts (lambda (a b) (string> (car a) (car b)))))) (mapconcat #'site/format-post-entry sorted-posts "\n")))
In our blog.org
file, this all goes into a code source block. Our headers are important: we only want to export the results produced from running the code block, not the code block itself.
src elisp :results raw :exports results (require 'dash) (defun site/extract-blog-metadata (file) "Extract title and date from org FILE." (save-excursion (find-file file) (let* ((keywords (org-collect-keywords '("TITLE" "DATE"))) (title (cdr (assoc "TITLE" keywords))) (date (cdr (assoc "DATE" keywords)))) (when (and title date) (list (car date) (car title) (file-name-nondirectory file)))))) (defun site/format-post-entry (post) "Generate a single entry for a post in the table." (let ((date (-first-item post)) (title (-second-item post)) (filename (-third-item post))) (format " | %s | [[file:blog/%s][%s]] |" date filename title))) (defun site/generate-blog-table () "Generate blog post table sorted by date." (let* ((blog-dir (expand-file-name "blog/" default-directory)) (org-files (directory-files blog-dir t "\\.org$")) (posts (delq nil (mapcar #'site/extract-blog-metadata org-files))) (sorted-posts (sort posts (lambda (a b) (string> (car a) (car b)))))) (mapconcat #'site/format-post-entry sorted-posts "\n"))) (site/generate-blog-table) src
Finally, just as a small configuration for ox-publish
and Org, we add these two lines in our publishing Emacs Lisp package:
(setq org-export-with-toc nil) (setq org-export-with-tags nil)
Hacking around on this blog is truly fun.