yet another emacs static blog

Table of Contents

1. Intro

I've been using zola to generate my static website for quite a while, but I've been wanting to replace it for a custom solution only because it looked like a fun project. My objectives:

  • To use Emacs org-mode for publishing the website (Emacs is my main driver for most of the day, and I'm a big fan of org-mode).
  • To learn a little bit of Lisp in the project (even when I'm a heavy Emacs user, I actually never actually got into lisp customization but for some copy-pasting on my config).
  • Not drift from the previous website design, but add the niceties from org-mode.

Inspired by this System Crafters post and this amazing write-up by Dennis Ogbe, I'll briefly explain what I did. My intention is not to make a deep dive into the code - Dennis post goes much more in-depth and is a great read if you're interested in a similar setup using Emacs as the static website engine.

2. Project

Much of the project structure is inspired by Dennis post I linked in the Intro, but instead of using the Emacs default HTML exporter for org files, he made his own exporter. I wanted my setup to be much simpler than his, so instead of making a custom exporter, I basically make the project in two steps: in the first step, I generate org files that are going to be included in other static pages using the #+INCLUDE directive (such as the list of most recent posts). Then I build the website with a custom preamble and postamble.

2.1. Project Structure

The project folder structure is:

website
┣ generated/      // Generated .org files during the build process
┣ include/        // Static files to be included in the project (such as preamble and postamble html code)
┣ lisp/           // Lisp scripts - right now, only site-build.el exists there
┣ org/            // CSS and JS source files
┣ public/         // Generated website
┣ sass/           // SCSS file that is compiled to a CSS file included in the static folder
┣ static/         // All of the other static files that I want to include in the project
┗ Makefile        // A single Makefile to centralize all the shell code that I need

2.2. site-build.el

This is the LISP script that builds the website. It is invoked by a shell Emacs instance called with emacs -Q --script lisp/build-site.el. I'll explain it in parts:

;; Set the package installation directory so that packages aren't stored in the
;; ~/.emacs.d/elpa path.
(require 'package)
(setq package-user-dir (expand-file-name "./.packages"))
(setq package-archives '(("melpa" . "https://melpa.org/packages/")
                         ("elpa" . "https://elpa.gnu.org/packages/")))

;; Initialize the package system
(package-initialize)
(unless package-archive-contents
  (package-refresh-contents))

;; Install dependencies
(package-install 'htmlize)

(require 'ox-publish)

This first part is a carbon-copy of System Crafters setup because I was quite fond of the idea of having the libraries installed locally in the project. For now the only package needed is htmlize, which for this project prettify all source code blocks.

;; Utility functions
(defun parent-directory (dir)
  "Return the parent directory of DIR or nil if at root."
  (let ((parent (file-name-directory (directory-file-name dir))))
    (unless (equal parent dir)
      parent)))


(defun search-project-root ()
  "Look for project root (Makefile) from 'default-directory' location."
  (search-project-root-aux default-directory))


(defun search-project-root-aux (dir)
  (unless (equal dir "/")
    (if (file-exists-p (concat (directory-file-name dir) "/Makefile"))
        dir
        (search-project-root-aux (parent-directory dir)))))


(defun load-file-as-string (f)
  "Return a string with file F content."
  (with-temp-buffer
    (insert-file-contents f)
    (buffer-substring-no-properties
     (point-min)
     (point-max))))


(defun list-files-in-directory (dir)
  "Return a list of files in directory dir, excluding '.' and '..'."
  (let ((files (directory-files dir t)) (ret nil))
    (setq ret (seq-filter (lambda (file)
                            (and (file-regular-p file)
                                 (not (string-match-p "/\\.\\.?$" file))))

                          files))

    (sort ret 'string-greaterp)
    ))


(defun get-post-keywords (file)
  "Return file post keywords. TITLE and DATE are required."
  (let ((ret ()))
    (with-temp-buffer
      (insert-file-contents file)
      (org-mode)
      (setq ret (org-collect-keywords '("TITLE" "DATE")))
      (unless (cadr (assoc "DATE" ret))
        (push (list "DATE" (substring (file-name-base file) 0 10)) ret)
        )
      )
    ret
    )
  )

There is a lot of utility functions, but since this was my incursion into Emacs lisp code and documentation, I wasn't sure what was built-in and what were the methods I had to create myself. Having said that, most of the utility functions are wrappers on small Emacs-lisp bits to make building the website easier.

To sort the posts in order of date, I'm sorting the file names in list-files-in-directory. This is using the fact that files are named with the date at the begging, like 2024-03-04-name-of-the-blog-post.org (I'm using this file name structure to make files ordered in my computer too).

get-post-keywords was an interesting case I used to simplify the post structure. Zola used front-matter and the YAML-esque structure at the beginning of markdown files to add those properties to the posts, such as:

+++
title = "creating a peer-to-peer snake game with godot webRTC"
date = 2023-09-07
[taxonomies]
tags = ["making-of", "webrtc", "multiplayer"]
+++

In my org file posts, I do the org equivalent:

#+TITLE: spring lisp game jam - devlog 2
#+DATE: 2024-04-13

I removed the "tags" attribute because I'd have to generate them myself and it looked like quite a hassle it didn't seem to add much to the website. The property #+DATE: is also optional - instead, I can just use the date in the file name.

;; Set project root
(setq project-root (search-project-root))

;; Set global variables
(setq org-html-preamble (load-file-as-string (concat project-root "/include/header.html")))
(setq org-html-postamble (load-file-as-string (concat project-root "/include/footer.html")))
(setq org-html-head-extra "<link rel=\"stylesheet\" type=\"text/css\" href=\"/static/main.css\" />")

This small excerpt sets the project root variable and some of the custom HTML I use for the preamble (the top of the website with the navigation links) and postamble (the bottom of the website, with my social contacts).

(defun prepare-recent-posts-list ()
  (let ((text "") (post-keywords ()) (posts (list-files-in-directory (concat (search-project-root) "org/posts/"))))
    (setq text "#+BEGIN_EXPORT html\n")

    (dolist (el (take 5 posts))
      (setq post-keywords (get-post-keywords el))
      (setq text (concat text (format "\
<div class=\"date\">
%s
</div>
<h5 style=\"margin:0px\"><a href=\"%s\">%s</a></h5>
<p/>\n"
                                      (cadr (assoc "DATE" post-keywords))
                                      (format "/posts/%s.html" (file-name-base el))
                                      (cadr (assoc "TITLE" post-keywords)))))
      )

    (setq text (concat text "#+END_EXPORT"))    
    (with-temp-file (concat (search-project-root) "generated/recent-posts.org") (insert text)))  
  )


(defun prepare-all-posts-list ()
  (let ((text "") (post-keywords ()))
    (setq text "#+BEGIN_EXPORT html\n")

    (dolist (el (list-files-in-directory (concat (search-project-root) "org/posts/")))
      (setq post-keywords (get-post-keywords el))
      (setq text (concat text (format "\
<div class=\"date\">
%s
</div>
<h5 style=\"margin:0px\"><a href=\"%s\">%s</a></h5>
<p/>\n"
                                      (cadr (assoc "DATE" post-keywords))
                                      (format "/posts/%s.html" (file-name-base el))
                                      (cadr (assoc "TITLE" post-keywords)))))
      )

    (setq text (concat text "#+END_EXPORT"))    
    (with-temp-file (concat (search-project-root) "generated/all-posts.org") (insert text)))
  )

This code is quite ugly, but I was almost done with the project and I was too excited to see the website getting built, so I just copy pasted the code between those two functions. Both methods generates the recent-posts.org and all-posts.org files that I use in the main page and "posts/" page of the website. It does so by getting the TITLE and DATE keywords from the org files and generating raw HTML using this information.

The files could be .html files instead of .org, but I find it more elegant to include .org files on other org files instead. I could add attributes or other kind of templating on them, but right now they are just raw HTML.

;; Define the publishing project
(setq org-publish-project-alist
      (list
       (list "main-pages"
             :recursive nil
             :with-toc nil
             :section-numbers nil
             :base-directory "./org"
             :publishing-directory "./public"
             :publishing-function 'org-html-publish-to-html)

       (list "posts"
             :recursive nil
             :base-directory "./org/posts"
             :publishing-directory "./public/posts"
             :publishing-function 'org-html-publish-to-html)))

This sets up org-publish-project-alist with two "projects": one is the main pages of the website (posts, about, etc.), and the other is all the posts. I have two different projects because the main pages of the website are slightly different, with no Table of Content and section numbers by default.

;; Generate the site output
(prepare-recent-posts-list)
(prepare-all-posts-list)

(org-publish-all t)

(shell-command "cp -R ./static ./public")

(message "Build complete!")

Finally, I run the code to generate the files I need and publish the entire website. Then I copy and static files into the /public folder where the static site is going to be served, and voilá!

3. Converting from zola

Converting from zola was quite straightforward. The preamble and postamble of my website are copy-pasted code from the generated zola page source-code, and I'm using the same SCSS file with some extra code to account for the things org export adds (such as Table of Content).

The posts needed some tweaking, though. First I used pandoc to convert all markdown files to org, then made some Emacs macros to run through the posts updating the obvious things (adding TITLE and DATE attributes, changing the file name to follow the "0000-00-00-title" format, etc).

4. Limitations of the current system

Right now, the most important thing that I'm lacking is RSS. This should be quite straightforard to add to the website using the current system, but I wanted to make an "innaugural" post for the new static website before moving forward.

One annoying thing is the path for static images in the post files. When adding a link to an image in Emacs org mode, it is very simple to navigate and find the image using org-insert-link (C-c C-l), but then it adds the full path of the image, which breaks the current process. I manually change it to a relative path, but I need to figure out how to improve this process. Not a deal breaker, but kind-of annoying.