Publishing a website with Emacs and Org

Bad ideas can eat a lot of time

Why?

To be honest, I’m not quite sure why I thought this would be a good idea. Something like Hugo seems to be the obvious choice. But I already use Emacs for everything else so why not. Hugo also does a lot more than I need (which, let’s be honest, is basically nothing) so I’d have to read a lot of documentation on how to not use things.

Emacs provides a mechanism to generate a HTML page from org files with ox-publish. It’s not overly user friendly or all that well documented but you can mostly get it to work, if the use case is rather simple.

How does this work?

Most of the magic lies in publish.el, as outlined here:

;; load up straight
;; the directory was already created by doom-emacs, might not fit for other setups
;; (defvar bootstrap-version)
;; (let ((bootstrap-file
;;        (expand-file-name ".local/straight/repos/straight.el/bootstrap.el" user-emacs-directory))
;;       (bootstrap-version 6))
;;   (load bootstrap-file nil 'nomessage))

;; ;; TODO: doom-emacs should have installed this, no idea why this is necessary
;; (straight-use-package 'htmlize)

(require 'org)
(require 'ox-publish)

(setq user-full-name "Björn Erlwein")

(defun bde/get-content (x)
  "Returns the contents of a file as a string"
  (with-temp-buffer
    (insert-file-contents x)
    (buffer-string)))

(defun bde/sitemap-format-entry (entry style project)
  "Custom sitemap entry formatting with date"
  (format
   "[[file:%s][%s \[%s\]]]"
   entry
   (org-publish-find-title entry project)
   (format-time-string "%Y-%m-%d" (org-publish-find-date entry project))))

(setq html-head (bde/get-content "./html/head.html"))
(setq html-preamble (bde/get-content "./html/preamble.html"))
(setq html-postamble (bde/get-content "./html/postamble.html"))

;; Some global settings that should apply everywhere, unless explicitely set locally
(setq
 ;; Don't want 1. or 2. in front of every headline
 org-export-with-section-numbers nil
 org-export-with-smart-quotes t
 org-export-with-toc nil
 ;; This wraps the different sections in the specified html tags, useful for styling
 org-html-divs '((preamble "header" "top")
                 (content "main" "content")
                 (postamble "footer" "postamble"))
 org-html-container-element "section"
 ;; Creates a small comment with a timestamp, why not
 org-html-metadata-timestamp-format "%d.%m.%Y"
 ;; Create html5 instead of xhtml 4
 org-html-html5-fancy t
 ;; For some reason not setting this will result in a xhtml doctype no matter if html5-fancy is set
 org-html-doctype "html5"
 ;; The default includes stuff I don't want/need, just use a fully custom styling
 org-html-head-include-default-style nil
 ;; these 3 replace the default sections with custom html files
 org-html-head html-head
 org-html-preamble html-preamble
 org-html-postamble html-postamble)

;; NOTE: Not used for now, static files are copied by the Makefile
(defvar site-attachments
  (regexp-opt '("css"))
  "File types that are published as static files.")

(setq org-publish-project-alist
      (list
       (list "bde-root"
             :base-directory "."
             :recursive t
             :publishing-function '(org-html-publish-to-html)
             :publishing-directory "./public"
             :exclude (regexp-opt '("README" "posts")))
       (list "bde-posts"
             :base-directory "./posts"
             :publishing-directory "./public/posts"
             :recursive t
             :with-toc t
             ;; The sitemap will serve as an automatic list of all posts
             ;; there is also :makeindex but I have no idea what this is supposed to do...
             :auto-sitemap t
             :sitemap-filename "index.org"
             :sitemap-format-entry (lambda (entry style project) (bde/sitemap-format-entry entry style project))
             ;; this has to be empty or it will be merged with the title of the index page...
             :sitemap-title ""
             ;; Newest posts should be at the top
             :sitemap-sort-files 'anti-chronologically)
       ;; (list "bde-static"
       ;;       :base-directory "."
       ;;       :exclude "public/"
       ;;       :base-extension site-attachments
       ;;       :publishing-directory "./public"
       ;;       :publishing-function 'org-publish-attachment
       ;;       :recursive t)
       ;; The order is important here
       ;; bde-posts creates posts/index.org which bde-root references
       (list "bde" :components '("bde-posts" "bde-root"))))

(provide 'publish)

The project is then “built” with a small Makefile:

SITE_FILES := $(shell fd --extension org --extension html)
STATIC_FILES := $(shell fd . ./static)
STATIC_OUT_FILES := $(subst static,public/static,$(STATIC_FILES))

IMAGE_VERSION = 1.0.0

SERVER := [email protected]
IDENTITY := ~/.ssh/webserver-nixos

all: public/index.html

public/static/%: static/%
        mkdir -p $(dir $@)
        cp $< $@

public/style.css: style.scss
        mkdir -p $(dir $@)
        sassc $< $@

public/index.html: publish.el public/style.css $(STATIC_OUT_FILES) $(SITE_FILES)
# load the personal config
# read the local source file
# generate the html
        emacs --batch \
                --eval "(package-initialize)" \
                --eval '(load-file "$<")' \
                --eval "(org-publish-all)"

##############################
# This is for the dev server #
##############################

server: http-server
        ./$< -c=css,html -i ./public

http-server:
        curl -L https://github.com/TheWaWaR/simple-http-server/releases/download/v$(DEVSERVER_VERSION)/x86_64-unknown-linux-musl-simple-http-server -o $@
        chmod +x @

publish:
        rsync -rauLv ./public -i $(IDENTITY) $(SERVER):/var/www/bde
        ssh -i $(IDENTITY) $(SERVER) 'chown -R nginx:nginx /var/www/bde'

clean:
        -rm -rf public
        -rm -rf tmp
        -rm -rf out
        -rm -rf posts/index.org
        -rm -rf ${HOME}/.org-timestamps/bde-root.cache
        -rm -rf ${HOME}/.org-timestamps/bde-posts.cache
        -rm -rf ${HOME}/.org-timestamps/bde-static.cache

.PHONY: server clean all publish

Everything is then packed up in a Docker container with Apache.

FROM httpd:alpine3.17

COPY ./public/ /usr/local/apache2/htdocs

Things I learned from doing this

This is really (really) slow without native-comp

My initial publish.el didn’t use the packages from straight and also didn’t use the native-comp feature of Emacs. This almost made me abort everything as publishing these few pages already took a minute. Using my straight.el setup provided by Doom Emacs sped the process up to sub 1 second, which is really nice.

The org cache can be quite annoying

Org tries to be helpful and caches processed files to avoid doing useless work, which makes sense considering the previous point. This seems to be a simple timestamp based process, similar to how make handles things. Which would be fine in theory but it doesn’t consider changes to something like the html-head or the preamble, which is really annoying while trying to assemble a somewhat working site out of all this.