Publish Org-Roam Notes as a Website

Table of Contents

Setting the Context

I love using emacs! I use it for the org mode. Org Mode is a mode for document editing, formatting, and organizing within the free software text editor GNU Emacs and its derivatives, designed for notes, planning, and authoring, says a quick google search. Emacs has a package named org-roam which let's us create and interlink org documents. If you have ever used obsidian, then you know what I am talking about.

I wanted to publish my org-roam wiki as a website so I searched on the internet. I found various suggestions like using ox-hugo or pandoc. Someone created a separate program for it hyperorg. I didn't like any of these solutions, because emacs can export org files to html, and doing so for a bunch of files in a folder shouldn't be that hard. Then I tried org-publish, but it errored on export. It was not identifying the org-roam links. Turns out that I have to run (org-roam-update-org-id-locations) after creating new notes for emacs to know about their locations on the filesystem.

Then there was another challenge, how could I publish some files, and not publish the rest, somewhat like drafts. There was no way of doing this with org-roam, or org-publish. org-publish does take a parameter :exclude "./exclude" to exclude specified directory, but I had all my roam notes in a single directory, so I couldn't use that. In the end I came up with something of my own.

How I made it work

First I added #+STATE: draft in the org-document-info area of the org-roam files. Then I wrote a little function with the help of ChatGPT to search for the files which don't have this in their document-info section. Once I had all those files, I published them using org-publish putting the html files in the ./public folder. I have all the attachments in ./assets folder, and I just copy it over to the public folder after html export. These are the rough steps.

  • Add #+STATE: draft to the files which you don't want to publish.
  • Add #+EXPORT_FILE_NAME: index.html to the file you want to export as index.html
  • Run (org-roam-update-org-id-locations).
  • Create build.sh and build-site.el in the root of your org-roam notes directory
  • run build.sh

It would be very annoying if I have to add #+STATE: draft to every org-roam note that I create. Turns out we can write some elisp to automate this. This code snippet below specifies the template for org-roam files. It adds #+AUTHOR:, #+EXCLUDE_TAGS:, and #+STATE:. In the index file, I have a lot of links to other notes, which I don't want to show yet, as they might be in WIP state. In such cases #+EXCLUDE_TAGS: comes in handy.

I added #+EXCLUDE_TAGS: noexport on the top of my index file (which I export as index.html). Now whichever heading I want to hide, I just have to add :noexport: at the end of it, and all the content under it will be hidden/will not be there in the exported file. This way, I can decide when is some note in a "ready-to-publish" state, and move it form a :noexport: heading to a normal one.

(setq org-roam-capture-templates
      '(("d" "default" plain
         "%?"
         :target (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
                            "#+TITLE: ${title}\n#+AUTHOR: Sahil Gautam\n#+EXCLUDE_TAGS: noexport\n#+STATE: draft\n"))))

build.sh

#!/bin/sh

# remove timestamp cache
if [ -d "$HOME/.org-timestamps" ]; then
    rm -rf "$HOME/.org-timestamps"
fi

# remove the html files, and build from scratch
rm -rf ./public
emacs -Q --script build-site.el

# move all the assets to the public directory
cp -r ./assets ./public

build-site.el

(require 'ox-publish)

(defun my-org-publish-filtered-files ()
  "Return a list of file paths to publish, excluding drafts."
  (let ((files (directory-files-recursively "." "\\.org$"))
        (filtered-files '()))
    (dolist (file files)
      (with-temp-buffer
        (insert-file-contents file)
        (unless (save-excursion
                  (goto-char (point-min))
                  (re-search-forward "^#\\+STATE: draft" nil t))
          (push file filtered-files))))
    (nreverse filtered-files)))

(defun my-org-publish-files (files)
  "Publish a list of files."
  (dolist (file files)
    (let ((org-publish-project-alist
           '(("my-org-site"
              :base-directory "."
              :publishing-directory "./public"
              :publishing-function org-html-publish-to-html
              :with-author nil           ;; Don't include author name
              :with-creator t            ;; Include Emacs and Org versions in footer
              :with-toc t                ;; Include a table of contents
              :section-numbers nil       ;; Don't include section numbers
              :time-stamp-file nil
              :recursive t))))
      (setq org-html-validation-link nil
            org-html-postamble nil
            org-html-head-include-scripts nil) ;; Use our own styles
      (org-publish-file file))))

;; Set up and publish
(let ((files (my-org-publish-filtered-files)))
  (my-org-publish-files files))