Published: 2025-01-04 Sat 16:10
Updated: 2025-01-08 Wed 16:26

Blogging with org-publish

Table of Contents

1. Intro

After scripting the export of Every Film I Watched in 2024 from my org-roam nodes (which I'll probably write a blog post about at a later date), I thought it was nigh time to stop procrastinating on my plans to set up a blog with Hugo or whatever. Unsurprisingly, org-publish made for a great workflow that ties in closely with how I already write, and is able to meet my specific needs of wanting to have a fancy flashy homepage that's kept separate from a low-spec blog.

2. Getting pages generated

As a starting point, I followed these two guides:

This was more than enough to generate pages with the kind of sitemap index I was after. There's not much to say about this stage, even. Just iteratively tweaking config settings and the various different settings in org-publish-project-alist, then running the build script.

3. Generating RSS

RSS was an absolute make-or-break for me having a blog, and potential complications (or just generally complicating the workflow) is probably the main reason I procrastinated so long on finally setting up a blogging workflow.

As I started researching, it looked like ox-rss would work, but unfortunately it turns out that it wants to convert an individual org doc by headline into an RSS XML. This would involve a lot of faffing to automatically generate the doc in order to automatically generate RSS, as well as needing to pull packages in the build script in order to actually get ox-rss. These were bad flags.

However, as RSS is pretty simple XML, and part of org-publish is iterating over blog entries anyway, I decided to script my own RSS. This ended up being simpler than I anticipated, and would easily recommend folk to roll their own if the need arises.

First, I'd need to extract the data I'd need for each RSS item when iterating over each page:

(setq rss-entry-data '())

(defun my/org-html-publish-to-html (plist filename pub-dir)
  (let ((is-processing-index (string-match-p "/index.org$" filename)))
    (when is-processing-index
      (setq plist (plist-put plist :html-preamble "<nav>
  <a href=\"/\">&lt; Home</a> <a href=\"/blog/\">&lt; Up</a> <a href=\"/blog/rss.xml\">&gt; rss</a>
</nav>
<div id=\"updated\">Updated: %C</div>"))
      (print plist))
    (let* ((pwd (file-name-directory (or load-file-name (buffer-file-name))))
         (output-file (org-html-publish-to-html plist filename pub-dir))
         (link (string-trim-left output-file pub-dir)))
      (when (not is-processing-index)
      (let ((buffer (find-file filename)))
        (with-current-buffer buffer
            (let* ((kwds (org-collect-keywords '("TITLE" "DATE" "DESCRIPTION")))
                 (title (cadr (assoc "TITLE" kwds)))
                 (timestamp-org (cadr (assoc "DATE" kwds)))
                 (desc (cadr (assoc "DESCRIPTION" kwds))))
              (cond ((null timestamp-org)
                   (user-error (format "Article '%s' missing DATE property." title)))
                  ((null desc)
                   (user-error (format "Article '%s' missing DESC property." title))))
            (push (list (list "title" title)
                        (list "date" (org-time-string-to-time timestamp-org))
                        (list "link" link)
                        (list "desc" desc))
                  rss-entry-data)))
        ;; for whatever reason, pub-dir ends up changed if I don't kill this buffer
        (kill-buffer buffer))))))

Next, after org-publish has finished building pages, I use that data to generate an RSS XML with simple string substitution:

(defun my/rss-data->rss-string (data)
  (let ((title (cadr (assoc "title" data)))
      (link (concat target-url (cadr (assoc "link" data))))
      (desc (cadr (assoc "desc" data)))
      (date (my/time-to-rfc822-string (cadr (assoc "date" data)))))
    (format "<item>
<title>%s</title>
<link>%s</link>
<description>%s</description>
<pubDate>%s</pubDate>
</item>"
          title
            link
            desc
          date)))

(defun my/time-to-rfc822-string (time)
  (format-time-string "%a, %d %b %Y %T %Z" time))

(with-temp-file "./public/blog/rss.xml"
  (insert (format "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>
<rss version=\"2.0\">
  <channel>
    <title>MenacingMecha Blog</title>
    <description>Long-form posts from MenacingMecha</description>
    <link>%s</link>
    <image>
      <url>https://www.menacingmecha.uk/assets/images/splash-screen_banner-anim.gif</url>
      <title>MenacingMecha Blog</title>
      <link>%s</link>
    </image>
%s
  </channel>
</rss>"
                target-url
                target-url
                (mapconcat 'my/rss-data->rss-string (reverse rss-entry-data)))))

From here, we have a fully formed RSS feed!

4. Next Steps & Closing Thoughts

Overall, I'm very happy with how this workflow has turned out. I might look into a little more styling (font colours for these source blocks would be nice), but I also don't mind these posts being very low-spec; save the flashy stuff for the homepage. I'm more than willing to sacrifice featuers if it means I can avoid pulling packages as part of the build script.

If you're interested, the source for these blog pages and the accompanying build script is available here: https://codeberg.org/MenacingMecha/blog