Custom org-sitemap-function post-Org 9.1

Published: December 27, 2022

I have been lugging around an old version of org-mode (9.0 to be specific) in the git repo which builds this website for a number of years now. I decided to do this because I had a custom org-sitemap-function to generate the landing page for my blog, but org 9.1 introduced a breaking change to the org-publish API.

I have now finally come around to fixing this issue and making my website compatible with modern emacs and org-mode versions higher than 9.1. However, porting my old sitemap function was… surprisingly difficult? So just in case someone is looking up my original post these days, this post contains a sitemap-function which will work in 2022.

In the pre-9.1 version, the org-sitemap-function would get the project-plist as argument. Post-9.1, the org manual states:

:sitemap-function

Plug-in function to use for generation of the sitemap. It is called with two arguments: the title of the site-map and a representation of the files and directories involved in the project as a nested list, which can further be transformed using org-list-to-generic, org-list-to-subtree and alike. Default value generates a plain list of links to all files in the project.

This makes it a little more difficult to produce the sitemap page that we want, but I was able to get it done by parsing each element of the list and extracting the path to the filename with the following function:

(defun my-blog-parse-sitemap-list (l)
  "Convert the sitemap list in to a list of filenames."
  (mapcar #'(lambda (i)
              (let ((link (with-temp-buffer
                            (let ((org-inhibit-startup nil))
                              (insert (car i))
                              (org-mode)
                              (goto-char (point-min))
                              (org-element-link-parser)))))
                (when link
                  (plist-get (cadr link) :path))))
          (cdr l)))

Finally, the new and improved sitemap function looks like this:

(defun my-blog-sort-article-list (l p)
  "sort the article list anti-chronologically."
  (sort l #'(lambda (a b)
              (let ((d-a (org-publish-find-date a p))
                    (d-b (org-publish-find-date b p)))
                (not (time-less-p d-a d-b))))))

(defun my-blog-sitemap (title list)
"Generate the landing page for my blog."
(with-temp-buffer
  ;; mangle the parsed list given to us into a plain lisp list of files
  (let* ((filenames (my-blog-parse-sitemap-list list))
         (project-plist (assoc "blog-articles" org-publish-project-alist))
         (articles (my-blog-sort-article-list filenames project-plist)))
    (dolist (file filenames)
            (let* ((abspath (file-name-concat my-website-blog-dir file))
                   (relpath (file-relative-name abspath my-website-base-dir))
                   (title (org-publish-find-title file project-plist))
                   (date (format-time-string (car org-time-stamp-formats) (org-publish-find-date file project-plist)))
                   (preview (my-blog-get-preview abspath)))
              ;; insert a horizontal line before every post, kill the first one
              ;; before saving
              (insert "-----\n")
              (insert (concat "* [[file:" relpath "][" title "]]\n"))
            ;; add properties for `ox-rss.el' here
            (let ((rss-permalink (concat (file-name-sans-extension relpath) ".html"))
                  (rss-pubdate date))
              (org-set-property "RSS_PERMALINK" rss-permalink)
              (org-set-property "PUBDATE" rss-pubdate))
            ;; insert the date, preview, & read more link
            (insert (concat "Published: " date "\n\n"))
            (insert preview)
            (insert "\n")
            (insert (concat "[[file:" relpath "][Read More...]]\n"))))
    ;; kill the first hrule to make this look OK
    (goto-char (point-min))
    (let ((kill-whole-line t)) (kill-line))
    ;; insert a title and save
    (insert "#+OPTIONS: title:nil\n")
    (insert "#+TITLE: Blog - Dennis Ogbe's Personal Website\n")
    (insert "#+AUTHOR: Dennis Ogbe\n")
    (insert "#+EMAIL: [email protected]\n")
    (buffer-string))))

This info can be combined with the instructions in my original post to cook up your own very special org-mode website.

Update December 29, 2022: I realized the info in the old post is very outdated. So here goes the current (late 2022) version of my website build script. I usually call this using emacs --batch, i.e., something like:

emacs --batch -l "./project.el" --eval="(org-publish \"blog\" t)"

As part of the build process, I use CSSTidy to minify my CSS and bibtex2html to generate the list of publications.

;; This file defines the org-publish project for my web site. -*- eval: (flycheck-mode -1) -*-

;; I can either run this from the build.sh script / Makefile or
;; evaluate this buffer and publish from within Emacs while editing
;; the page.

(defun generate-website (arg)
  "Generate my website. Call with prefix argument for a complete rebuild."
  (interactive "P")
  (message "Generating website for staging...")
  (if arg
      ;; force rebuild everything
      (org-publish "blog" t nil)
    ;; only rebuild what changed
    (org-publish "blog" nil nil))
  (message "Done. Check output in %s" my-website-out-dir))

;; I am not sure why I have to do it this way... This snippet finds
;; the parent directory of this file, which is the base directory of
;; the project.
(setq my-website-base-dir
      (file-name-as-directory
       (file-name-directory
        (directory-file-name
         (file-name-directory
          (or load-file-name buffer-file-name))))))

;; set up the rest of the directory tree
(defmacro my-website-set-path-var (name)
  (list 'setq (intern (format "my-website-%s-dir" name))
        (list 'file-name-as-directory (concat my-website-base-dir name))))
(my-website-set-path-var "bin")
(my-website-set-path-var "bib")
(my-website-set-path-var "blog")
(my-website-set-path-var "css")
(my-website-set-path-var "cv")
(my-website-set-path-var "dl")
(my-website-set-path-var "html")
(my-website-set-path-var "img")
(my-website-set-path-var "lisp")
(my-website-set-path-var "pages")

;; we pull the output directory out of an environment variable. If this
;; variable is not set, we bail
(setq my-website-out-dir (getenv "WEBSITE_OUT_DIR"))
(unless my-website-out-dir
  (setq my-website-out-dir (file-name-concat my-website-base-dir "www"))
  (message "Using default WEBSITE_OUT DIR: %s" my-website-out-dir))
(setq my-website-out-dir (file-name-as-directory my-website-out-dir))

; https://adam.kruszewski.name/2022-05-08-org-publish-call-org-fold-core-region-error.html
; (setq org-fold-core-style  'text-properties) 

;; [2022-12-27 Tue] This is now compatible with emacs 28.1. it
;; requires the `htmlize' and `org-contrib' packages.
(package-initialize)
(require 'org)
(require 'htmlize)
(require 'org-contrib)
(require 'ox-html)
(require 'ox-rss)
(require 'oc-csl)
(require 'citeproc)
(require 'parsebib)
(require 'rx)

;; fix obsolete `font-lock-reference-face'
;; (see https://lists.nongnu.org/archive/html/emacs-devel/2022-09/msg01022.html)
(setq font-lock-reference-face font-lock-constant-face)

;; re-build the entire project if $WEBSITE_BUILD_TYPE=FULL
(when (and (getenv "WEBSITE_BUILD_TYPE")
           (string-equal (downcase (getenv "WEBSITE_BUILD_TYPE")) "full"))
  (setq org-publish-use-timestamps-flag nil))

;; references --------------------------------------------------------

(setq org-cite-export-processors
      `((t . (csl ,(file-name-concat my-website-bib-dir "ieee-mod.csl")))))

;; html export settings ----------------------------------------------

(setq org-export-html-coding-system 'utf-8-unix)
(setq org-html-htmlize-output-type 'css)
(setq org-html-doctype "xhtml5")
(setq org-html-html5-fancy t)
(add-to-list 'org-babel-default-header-args '(:eval . "never-export"))

(setq org-export-time-stamp-file nil)
(setq org-rss-use-entry-url-as-guid nil)

;; massage org-time-stamps
(setq org-time-stamp-custom-formats '("%B %d, %Y" . "%B %d, %Y, %H:%M"))
(setq org-display-custom-times t)
(setq org-export-date-timestamp-format (car org-time-stamp-custom-formats))
(defun my-org-export-ensure-custom-times (backend)
  (setq-local org-display-custom-times t)
  (setq-local org-export-date-timestamp-format (car org-time-stamp-custom-formats)))
(add-hook 'org-export-before-processing-hook 'my-org-export-ensure-custom-times)

;; <2024-03-10 Sun> Need to fix `org-html-timestamp', since it calls
;; `org-timestamp-translate', which does not strip brackets.. why.
(defun my-format-timestamp (ts)
  "Format a time stamp using org-mode according to `org-time-stamp-custom-formats'."
  (org-format-timestamp ts (org-time-stamp-format (org-timestamp-has-time-p ts)
                                                  'no-brackets
                                                  t)
                        t))
(defun override-org-html-timestamp (timestamp _contents info)
  (let ((value (org-html-plain-text (my-format-timestamp timestamp) info)))
    (format "<span class=\"timestamp-wrapper\"><span class=\"timestamp\">%s</span></span>"
            (replace-regexp-in-string "--" "&#x2013;" value))))

(advice-add 'org-html-timestamp :override #'override-org-html-timestamp)

;; we do not need backup files for this
(setq make-backup-files nil)

;; we evaluate some elisp to generate some html. this lets us do that.
(setq org-confirm-babel-evaluate nil)

(setq website-head
  (concat
   "<script src='/../res/code.js'></script>\n" ; some very simple javascript
   "<link rel='stylesheet' href='/../res/style.css' />\n" ; minified style sheet
   "<link rel='shortcut icon' type=\"image/ico\" href='/../img/favicon.ico'/>\n" ; favicon
   "<link rel='alternate' type='application/rss+xml' title='RSS Feed for ogbe.net' href='/blog-feed.xml' />\n"))

;; header and footer

(defun website-header (info)
  (with-temp-buffer
    (insert-file-contents (concat my-website-html-dir "header.html"))
    (buffer-string)))

(defun website-footer (info)
  (with-temp-buffer
        (insert-file-contents (concat my-website-html-dir "footer.html"))
        (buffer-string)))

(defun my-blog-org-export-format-drawer (name content)
  (concat "<div class=\"drawer " (downcase name) "\">\n"
          "<h6>" (capitalize name) "</h6>\n"
          content
          "\n</div>"))

(defun minify (what file)
  (let ((tool (file-name-concat my-website-bin-dir
                                (cond ((eq what 'js) "rjsmin.py")
                                      ((eq what 'css) "rcssmin.py")
                                      (t (error "unknown format: %s" what))))))
    (shell-command-to-string (format "python3 %s < %s" tool file))))
(defun minify-css (file) (minify 'css file))
(defun minify-js (file) (minify 'js file))


(setq my-blog-local-mathjax
      '((path "/mathjax/tex-chtml.js")
        (scale "100") (align "center") (indent "2em") (tagside "right") (autonumber "all")
        (mathml nil)))
(setq my-blog-local-mathjax '((path "/mathjax/tex-chtml.js")))
(setq my-blog-extra-mathjax-config
      (concat "<script>"
              "MathJax = { tex: { inlineMath: [['$', '$'], ['\\\\(', '\\\\)']], tags: 'ams' }, svg: { fontCache: 'global' }};"
              "</script>"))

(defun my-blog-get-preview (file)
  "The comments in FILE have to be on their own lines, prefereably before and after paragraphs."
  (with-temp-buffer
    (insert-file-contents file)
    (goto-char (point-min))
    (let ((beg (+ 1 (re-search-forward "^#\\+BEGIN_PREVIEW$")))
          (end (progn (re-search-forward "^#\\+END_PREVIEW$")
                      (match-beginning 0))))
      (buffer-substring beg end))))

(defun my-blog-parse-sitemap-list (l)
  "Convert the sitemap list in to a list of filenames."
  (mapcar #'(lambda (i)
              (let ((link (with-temp-buffer
                            (let ((org-inhibit-startup nil))
                              (insert (car i))
                              (org-mode)
                              (goto-char (point-min))
                              (org-element-link-parser)))))
                (when link
                  (plist-get (cadr link) :path))))
          (cdr l)))

(defun my-blog-sort-article-list (l p)
  "sort the article list anti-chronologically."
  (sort l #'(lambda (a b)
              (let ((d-a (org-publish-find-date a p))
                    (d-b (org-publish-find-date b p)))
                (not (time-less-p d-a d-b))))))

(defun my-blog-sitemap (title list)
  "Generate the landing page for my blog.

This actually generate two pages. one for the RSS output and one
for the actual landing page."
  (let* ((filenames (my-blog-parse-sitemap-list list))
         (project-plist (assoc "blog-articles" org-publish-project-alist))
         (rss-output-file (file-name-concat my-website-blog-dir "blog-feed.org")))

    ;; first generate the org file for the RSS output
    (with-temp-buffer
      (insert "#+AUTHOR: Dennis Ogbe\n")
      (insert "#+EMAIL: [email protected]\n")
      (insert
       (mapconcat
        (lambda (file)
          (let* ((abspath (file-name-concat my-website-blog-dir file))
                 (relpath (file-relative-name abspath my-website-base-dir))
                 (title (org-publish-find-title file project-plist))
                 (date (format-time-string (car org-time-stamp-formats)
                                           (org-publish-find-date file project-plist)))
                 (rss-permalink (file-name-sans-extension relpath))
                 (preview (my-blog-get-preview abspath)))
            (with-temp-buffer
              ;; need to make this an org-mode buffer
              (org-mode)
              ;; insert the link to the article as h2
              (insert (concat "* [[file:" relpath "][" title "]]\n"))
              ;; add properties for `ox-rss.el' here
              (org-set-property "RSS_PERMALINK" rss-permalink)
              (org-set-property "PUBDATE" date)
              (org-set-property "RSS_TITLE" title)
              ;; insert the preview
              (insert preview)
              (buffer-string))))
        filenames "\n"))
      (write-file rss-output-file))

    ;; now generate the actual Blog landing page
    (with-temp-buffer
      ;; insert a title and save
      (insert "#+TITLE: Blog - Dennis Ogbe's Personal Website\n")
      (insert "#+AUTHOR: Dennis Ogbe\n")
      (insert "#+EMAIL: [email protected]\n\n")
      (insert "#+OPTIONS: title:nil\n")
      (insert "@@html:<h1>Blog</h1>@@\n\n") ; this way the browser's tab shows ^ but the site shows <
      ;; only display a full preview for the first 10 posts
      (let* ((nfull 10)
             (title-preview (seq-subseq filenames 0 (1- nfull)))
             (title-only (seq-subseq filenames nfull)))
        (insert
         (mapconcat
          (lambda (file)
            (let* ((abspath (file-name-concat my-website-blog-dir file))
                   (relpath (file-relative-name abspath my-website-base-dir))
                   (title (org-publish-find-title file project-plist))
                   (date (format-time-string (car org-time-stamp-custom-formats)
                                             (org-publish-find-date file project-plist)))
                   (preview (my-blog-get-preview abspath)))
              (with-temp-buffer
                ;; insert the link to the article as h2
                (insert (concat "* [[file:" relpath "][" title "]]\n"))
                ;; insert the date, preview, and read more link
                (insert (concat "/Published: " date "/\n\n"))
                (insert preview)
                (insert "\n")
                (insert (concat "[[file:" relpath "][/Read More.../]]\n"))
                (buffer-string))))
          title-preview "\n-----\n"))
        ;; For the remaining articles, show them as a list
        (insert "\n-----\n")
        (insert "@@html:<h1>Archive</h1>@@\n\n")
        (insert 
         (mapconcat
          (lambda (file)
            (let* ((abspath (file-name-concat my-website-blog-dir file))
                   (relpath (file-relative-name abspath my-website-base-dir))
                   (title (org-publish-find-title file project-plist))
                   (date (format-time-string "%Y-%m-%d" (org-publish-find-date file project-plist))))
              (format "- %s: [[file:%s][%s]]" date relpath title)))
          title-only "\n")))
      (buffer-string))))

;; pre- and post-processing
(defun my-blog-pages-preprocessor (project-plist)
  (message "In the pages preprocessor."))

(defun my-blog-pages-postprocessor (project-plist)
  (message "In the pages postprocessor."))

(defun my-blog-articles-preprocessor (project-plist)
  (message "In the articles preprocessor."))

(defun my-blog-articles-postprocessor (project-plist)
  "Massage the sitemap file and move it up one directory.

  for this to work, we have already fixed the creation of the
  relative link in the sitemap-publish function"
  (let* ((sitemap-fn (concat (file-name-sans-extension (plist-get project-plist :sitemap-filename)) ".html"))
         (sitemap-olddir (plist-get project-plist :publishing-directory))
         (sitemap-newdir (expand-file-name (concat (file-name-as-directory sitemap-olddir) "..")))
         (sitemap-oldfile (expand-file-name sitemap-fn sitemap-olddir))
         (sitemap-newfile (expand-file-name (concat (file-name-as-directory sitemap-newdir) sitemap-fn))))
    (with-temp-buffer
      (goto-char (point-min))
      (insert-file-contents sitemap-oldfile)
      ;; massage the sitemap if wanted

      ;; delete the old file and write the correct one
      (delete-file sitemap-oldfile)
      (write-file sitemap-newfile))))

(defun my-blog-articles-add-subheader (plist filename pub-dir)
  "Called after the publishing function, this adds a subheader to each blog post."
  (let* ((outfile (file-name-concat pub-dir (concat (file-name-base filename) ".html")))
         (date (format-time-string (car org-time-stamp-custom-formats) (org-publish-find-date filename plist)))
         (author (org-publish-find-property filename 'author plist)) ; unused
         (re (regexp-quote "<h1 class=\"title\">")))
    ;; open the outfile and splice publishing date into the generated HTML
    (with-temp-buffer
      (insert-file-contents outfile)
      (when (re-search-forward re nil t)
        (end-of-line)
        (insert (format "\n<div class=\"subheader\"><p><i>Published: %s</i></p></div>" date)))
      (write-file outfile))))

(defvar my-website-cssjs-files nil
  "A list of alists defining which files from the `css' directory
to concatenate or minify.")
(setq my-website-cssjs-files
      '(;; the main CSS style for pages
        ((output . "style.css")
         (contents . ("fonts.css.in" "code.css.in" "main.css.in"))
         (processor . minify-css))
        ;; the CSS style for pages with margin
        ((output . "margin-style.css")
         (contents . ("margin.css.in"))
         (processor . minify-css))
        ;; the CSS style for the RSS feed
        ((output . "rss.css")
         (contents . ("fonts.css.in" "code.css.in" "rss.css.in"))
         (processor . minify-css))
        ;; the JavaScript code
        ((output . "code.js")
         (contents . ("code.js.in"))
         (processor . minify-js))))

(defun my-website-process-cssjs (project-plist)
  "Process the variable `my-website-cssjs-files' and produce
outputs in `my-website-css-dir'."
  (let ((base-dir (expand-file-name (plist-get project-plist :base-directory))))
    (mapcar
     (lambda (spec)
       (let ((output (file-name-concat base-dir (alist-get 'output spec))))
         (with-temp-buffer
           (insert
            (mapconcat (symbol-function (alist-get 'processor spec))
                       (mapcar (lambda (f) (file-name-concat base-dir f))
                               (alist-get 'contents spec))
                       "\n"))
           (write-file output))))
     my-website-cssjs-files)))

(defun my-website-clean-cssjs (project-plist)
  "Clean up the generated outputs in `my-website-css-dir'."
  (let ((base-dir (expand-file-name (plist-get project-plist :base-directory))))
    (mapcar
     (lambda (spec)
       (let ((output (file-name-concat base-dir (alist-get 'output spec))))
         (delete-file output)))
     my-website-cssjs-files)))

(defun my-website-rss-postprocessor (info)
  "Add a stylesheet tag to the RSS feed exported by `ox-rss.' This
is included in more recent versions of `ox-rss', but seemingly
did not make it into Emacs 28.1."
  (let ((outfile (file-name-concat (plist-get info :publishing-directory)
                                   "blog-feed.xml"))
        (stylesheet (plist-get info :my-rss-stylesheet)))
    (when stylesheet
      (with-temp-buffer
      (insert-file-contents outfile)
      (goto-char (point-min))
      (replace-regexp (rx "<?xml version=\"1.0\" encoding=\""
                          (* anything)
                          "\"?>")
                      (format "\\&\n<?xml-stylesheet type=\"text/xsl\" href=\"%s\"?>" stylesheet))
      (write-file outfile)))))

;; generate list of publications using `citeproc.el'
(defun generate-bib-html (relfile)
  (let* ((infile (file-name-concat my-website-bib-dir relfile))
         (csl-style (file-name-concat my-website-bib-dir "ieee-mod.csl"))
         (csl-locale (file-name-concat my-website-bib-dir "locales-en-US.xml"))
         (bib-entries ;; get all keys in the file as a list
          (let (keys)
            (maphash (lambda (k v) (push k keys))
                     (with-temp-buffer (insert-file-contents infile)
                                       (parsebib-collect-bib-entries :fields '("key"))))
            keys))
         (cproc (citeproc-create csl-style
                                 (citeproc-hash-itemgetter-from-any infile)
                                 (citeproc-locale-getter-from-dir my-website-bib-dir)
                                 "en-US" t)))
    ;; add all keys to the citation processor
    (citeproc-append-citations
     (mapcar (lambda (k) (citeproc-citation-create :cites `(((id . ,k)))) )
            bib-entries)
     cproc)
    ;; render the bibliography
    (with-temp-buffer
      (insert (car (citeproc-render-bib cproc 'html nil nil nil)))
      ;; highlight my name
      ;; FIXME should be possible with
      ;; `citeproc-name-postprocess-functions', but this seems to not
      ;; be supported in my version. (see
      ;; https://andras-simonyi.github.io/org-csl-cv-bib-tutorial/)
      (goto-char (point-min))
      (replace-regexp (rx "D." (or (* "&nbsp;") (* whitespace)) "Ogbe") "<b>D.&nbsp;Ogbe</b>")
      (buffer-substring (point-min) (point-max)))))

;; CUSTOM HTML BACKEND -----------------------------------------------

;; define a custom export backend to use when publishing to HTML that
;; properly handles footnotes and margin notes.

(org-export-define-derived-backend 'ogbe-html 'html
  :translate-alist
  '((footnote-reference . ogbe-html-footnote-reference)
    (link . ogbe-html-maybe-margin-note-link)
    (special-block . ogbe-html-special-block)))

(defun org-html-publish-to-ogbe-html (plist filename pub-dir)
  "Publish an org file to using my custom backend.

PLIST is the property list for the given project.  FILENAME is
the filename of the Org file to be published.  PUB-DIR is the
publishing directory.

Return output file name."
  (org-publish-org-to 'ogbe-html filename
                      (concat "." (or (plist-get plist :html-extension)
                                      org-html-extension
                                      "html"))
                      plist pub-dir))

(defun ogbe-html-footnote-reference (footnote-reference contents info)
  (let ((text  (replace-regexp-in-string
                ;; footnotes must have spurious <p> tags removed or they will not work
                "</?p.*>" ""
                (org-trim
                 (org-export-data
                  (org-export-get-footnote-definition footnote-reference info)
                  info))))
        (num (org-export-get-footnote-number footnote-reference info)))
    (format (concat "<span id=\"fnr.%d\" class=\"sidenote-number\">"
                    "<a href=\"#fn.%d\"><sup>%s</sup></a></span>"
                    "<span class=\"sidenote\" id=\"sn.%d\">"
                    "<span><sup>%d</sup></span>"
                    "%s</span>")
            num num num num num text)))

(defun ogbe-html-format-margin-note (text id)
  (format "<span class=\"marginnote\" id=\"mn.%s\">%s</span>" id text))

(defun ogbe-html-maybe-margin-note-link (link desc info)
  ;; check if a link points to a #+BEGIN_margin ... #+END_margin
  ;; block. if yes, insert it as margin note inline.
  (let* ((path (org-element-property :path link))
         (type (org-element-property :type link))
         ;; check whether we are at a margin link.
         (margin-link (and (string= "fuzzy" type)
                           (string= "mn" (car (split-string path ":")))))
         ;; attempt to follow the link and check if we are looking at a margin block
         (margin-block (when (and (string= "fuzzy" type) (not margin-link))
                         (let ((elem (org-export-resolve-fuzzy-link link info)))
                           (when (and (eq (org-element-type elem) 'special-block)
                                      (string= "margin" (org-element-property :type elem)))
                             elem)))))
    (cond (margin-link
           ;; for a link-style margin note, replicate the behavior of
           ;; `ox-tufte.el' and just copy the link text without any
           ;; processing.
           (let ((text (replace-regexp-in-string "</?p.*>" "" desc))
                 (id (cadr (split-string path ":"))))
             (ogbe-html-format-margin-note text id)))
          (margin-block
           ;; for a rich block-style margin note, we need to export
           ;; the contents of the block and insert it inline. since at
           ;; this point, we do NOT want to skip the translation of
           ;; the "margin" blocks, we need to export the block with an
           ;; anonymous backend (see
           ;; https://orgmode.org/worg/dev/org-export-reference.html)
           (let ((text (org-export-data-with-backend
                        margin-block
                        (org-export-create-backend
                         :parent 'ogbe-html
                         :transcoders
                         `((special-block . ,(lambda (block contents info)
                                               contents))
                           (paragraph . ogbe-html-margin-paragraph)
                           (src-block . ogbe-html-margin-src-block)))
                        info))
                 (id (org-element-property :name margin-block)))
             (ogbe-html-format-margin-note text id)))
          (t (org-html-link link desc info)))))

(defun ogbe-html-special-block (special-block contents info)
  "Transcode a SPECIAL-BLOCK element from Org to HTML.

CONTENTS holds the contents of the block.  INFO is a plist
holding contextual information.

This function implements some special cases. For example, for
`margin' blocks, we do not output anything, since they are
handled by `ogbe-html-maybe-margin-note-link.'"
  (let ((block-type (org-element-property :type special-block)))
    (cond ((string= block-type "margin") "")
          (t (org-html-special-block special-block contents info)))))

(defun ogbe-html-margin-paragraph (paragraph contents info)
  "Transcode a PARAGRAPH element from Org to HTML.

CONTENTS is the contents of the paragraph, as a string.  INFO is
the plist used as a communication channel.

This is a modified version of `org-html-paragraph' which does not
wrap the figure in a <figure> tag, but instead uses a custom span
element."
  (let* ((parent (org-export-get-parent paragraph))
         (parent-type (org-element-type parent))
         (style '((footnote-definition " class=\"footpara\"")
                  (org-data " class=\"footpara\"")))
         (attributes (org-html--make-attribute-string
                      (org-export-read-attribute :attr_html paragraph)))
         (extra (or (cadr (assq parent-type style)) "")))
    (cond
     ((and (eq parent-type 'item)
           (not (org-export-get-previous-element paragraph info))
           (let ((followers (org-export-get-next-element paragraph info 2)))
             (and (not (cdr followers))
                  (memq (org-element-type (car followers)) '(nil plain-list)))))
      ;; First paragraph in an item has no tag if it is alone or
      ;; followed, at most, by a sub-list.
      contents)
     ((org-html-standalone-image-p paragraph info)
      ;; insert an image with the img tag
      (format "<span class=\"margin-img\">%s</span>" contents))
     ;; Regular paragraph. Don't insert <p> tag, since we are making a margin note
     (t contents))))

(defun ogbe-html-margin-src-block (src-block contents info)
    "Transcode a SRC-BLOCK element from Org to HTML.
CONTENTS holds the contents of the item.  INFO is a plist holding
contextual information.

This outputs a <code> tag in a <span> instead of a <pre> tag in a
<div> to make this work in the margin."
    (let ((lang (org-element-property :language src-block))
          (code (org-html-format-code src-block info))
          (label (let ((lbl (org-html--reference src-block info t)))
                    (if lbl (format " id=\"%s\"" lbl) ""))))
      (format "<code class=\"src src-%s margin-src-block\"%s>\n\n%s\n</code>"
              lang label code)))

;; override org-html-footnotes-section to generate a nicer footnotes
;; section

(defun ogbe-html-footnote-section (info)
  "Format the footnote section.

INFO is a plist used as a communication channel. This is a respin
of `org-html-footnote-section.'"
  (let ((defs (org-export-collect-footnote-definitions info))
        ;; make a table within a details block
        (fmt (concat "<details id=\"footnotes-details\" closed>"
                     "<summary class=\"footnote-summary\">Footnotes</summary>\n"
                     "<div class=\"footnote-table\">\n"
                     "<table style=\"margin: 0 0 0 0; max-width:100%%\">\n"; table header
                     "%s\n" ; 1 row/footnote (see later)
                     "</table></div></details>\n")))
    (when defs
      (format fmt (mapconcat
                   ;; most of this stolen from `org-html-footnote-section.'
                   (lambda (definition)
                     (pcase definition
                       (`(,n ,_ ,def)
                        (let* ((inline? (not (org-element-map def org-element-all-elements
                                               #'identity nil t)))
                               (anchor (org-html--anchor
                                        (format "fn.%d" n)
                                        n
                                        (format " class=\"footnum\" href=\"#fnr.%d\" role=\"doc-backlink\"" n)
                                        info))
                               (contents (org-trim (org-export-data def info)))
                               (text (if (not inline?) contents
                                       (format "<p class=\"footpara\">%s</p>" contents))))
                          ;; create a HTML table row instead.
                          (format (concat "<tr valign=\"top\">\n" ; row
                                          "<td align=\"right\" class=\"fn-tbl-number\" style=\"padding: 0.8rem 0.5rem 0 0.5rem;\">%s</td>\n" ; number
                                          "<td align=\"left\" class=\"fn-tbl-item\" style=\"padding: 0.8rem 0 0 0;\">%s</td>" ; text
                                          "</tr>")
                                  anchor text)))))
                   defs "\n")))))

(advice-add 'org-html-footnote-section :override #'ogbe-html-footnote-section)

;; wrap list items into paragraphs to make footnotes work from lists.

(defun ogbe-html-fix-list-item (item)
  "Wrap the contents of the list item ITEM into <p> tags."
  (setq item (replace-regexp-in-string "<li>" "<li><p>" item))
  (setq item (replace-regexp-in-string "<dd>" "<dd><p>" item))
  (setq item (replace-regexp-in-string "</li>" "</p></li>" item))
  (setq item (replace-regexp-in-string "</dd>" "</p></dd>" item)))

(advice-add 'org-html-format-list-item :filter-return #'ogbe-html-fix-list-item)

;; PROJECT ALIST -----------------------------------------------------

;; define a test mode in which we only build the test page
(defvar my-website-test-mode nil "Enable test mode")
(when (getenv "WEBSITE_TEST_MODE")
  (setq my-website-test-mode t))


;; finally, pull the project together in the `org-publish-project-alist'
(setq org-publish-project-alist
      `(("blog"
         :components ,(if my-website-test-mode
                          '("blog-css" "blog-rss" "blog-pages")
                        '("blog-articles" "blog-rss" "blog-pages" "blog-css" "blog-images" "blog-dl")))
        ("blog-articles"
         :base-directory ,my-website-blog-dir
         :base-extension "org"
         :publishing-directory ,(concat my-website-out-dir "blog")
         :publishing-function (org-html-publish-to-ogbe-html my-blog-articles-add-subheader)
         :preparation-function my-blog-articles-preprocessor
         :completion-function my-blog-articles-postprocessor
         :htmlized-source t ;; this enables htmlize, which means that I can use css for code!

         :headline-level 4
         :section-numbers nil
         :with-toc nil
         :with-drawers t
         :with-sub-superscript nil ;; important!!

         ;; the following removes extra headers from HTML output -- important!
         :html-link-home "/"
         :html-head ,website-head
         :html-head-include-default-style nil
         :html-head-include-scripts nil

         :html-format-drawer-function my-blog-org-export-format-drawer
         :html-home/up-format ""
         :html-mathjax-options ,my-blog-local-mathjax
         :html-mathjax-template ,(concat my-blog-extra-mathjax-config "\n<script type=\"text/javascript\" src=\"%PATH\"></script>")
         :html-link-up ""
         :html-link-home ""
         :html-preamble website-header
         :html-postamble website-footer

         ;; sitemap - list of blog articles
         :auto-sitemap t
         :sitemap-filename "blog.org"
         :sitemap-title "Blog"
         :exclude "blog-feed.org"

         ;; custom sitemap generator function
         :sitemap-function my-blog-sitemap
         :sitemap-function org-publish-sitemap-default
         :sitemap-sort-files anti-chronologically
         :sitemap-date-format "Published: %a %b %d %Y")
        ("blog-pages"
         :base-directory ,my-website-pages-dir
         :base-extension ,(if my-website-test-mode "nothing"
                            "org")
         :include ("test-page.org" "publications.org")
         :publishing-directory ,my-website-out-dir
         :publishing-function org-html-publish-to-ogbe-html
         :preparation-function my-blog-pages-preprocessor
         :completion-function my-blog-pages-postprocessor
         :htmlized-source t

         :headline-level 4
         :section-numbers nil
         :with-toc nil
         :with-drawers t
         :with-sub-superscript nil ;; important!!

         ;; the following removes extra headers from HTML output -- important!
         :html-link-home "/"
         :html-head ,website-head
         :html-head-include-default-style nil
         :html-head-include-scripts nil

         :html-format-drawer-function my-blog-org-export-format-drawer
         :html-home/up-format ""
         :html-mathjax-options ,my-blog-local-mathjax
         :html-mathjax-template ,(concat my-blog-extra-mathjax-config "<script type=\"text/javascript\" src=\"%PATH\"></script>")
         :html-link-up ""
         :html-link-home ""

         :html-preamble website-header
         :html-postamble website-footer)
        ("blog-rss"
         :base-directory ,my-website-blog-dir
         :base-extension "org"
         :publishing-directory ,my-website-out-dir
         :publishing-function org-rss-publish-to-rss
         :with-author t

         :html-link-home "https://ogbe.net/"
         :html-link-use-abs-url t

         :title "Dennis Ogbe"
         :rss-image-url "https://ogbe.net/img/feed-icon-28x28.png"
         :my-rss-stylesheet "/res/rss.xsl" ; custom style sheet
         :completion-function my-website-rss-postprocessor
         :section-numbers nil
         :exclude ".*"
         :include ("blog-feed.org")
         :table-of-contents nil)
        ("blog-css"
         ; see my-website-cssjs-files as example for how these are published
         :base-directory ,my-website-css-dir
         :base-extension ".*"
         :exclude ,(rx (* anything) (or "~" "#" ".in"))
         :publishing-directory ,(concat my-website-out-dir "res")
         :publishing-function org-publish-attachment
         :preparation-function my-website-process-cssjs
         :completion-function my-website-clean-cssjs
         :recursive t)
        ("blog-images"
         :base-directory ,my-website-img-dir
         :base-extension ".*"
         :publishing-directory ,(concat my-website-out-dir "img")
         :publishing-function org-publish-attachment
         :recursive t)
        ("blog-dl"
         :base-directory ,my-website-dl-dir
         :base-extension ".*"
         :publishing-directory ,(concat my-website-out-dir "dl")
         :publishing-function org-publish-attachment
         :recursive t)))

Update January 02, 2023: I changed a few minor things, including that I now finally add the publish date into the actual published blog post. Also, thanks to a hint by G.M., who provided a fix for my old structure-template definition, I now have an updated structure template to generate the header for a blog post (see here for an explanation):

(require 'org-tempo)
(tempo-define-template "blog-header" ; just some name for the template
                       '("#+title: ?" n
                         "#+AUTHOR: Dennis Ogbe" n
                         "#+EMAIL: [email protected]" n
                         "#+DATE:" n
                         "#+STARTUP: showall" n
                         "#+STARTUP: inlineimages" n
                         "#+BEGIN_PREVIEW" n p n
                         "#+END_PREVIEW")
                       "<b"
                       "Insert blog header" ; documentation
                       'org-tempo-tags)