Org to HTML and back

Blog post about publishing my blog with Org Mode

Table of Contents

Disclaimer

I'm neither proficient in Org Mode (further on "Org"), nor a good front-end engineer. I think that a simple solution is better than no solution. If you see a mistake, you can contact me via iam@fidonode.me.

What is Org?

Your life in plain text

A GNU Emacs major mode for keeping notes, authoring documents, computational notebooks, literate programming, maintaining to-do lists, planning projects, and more — in a fast and effective plain text system.

Everything you can do in Org is to write a text. With a special markup, of course. This makes it versatile and extensible.

Why Org Mode?

  1. Plain text. Plain text as a data source offers significant versatility. You can read and understand what happens in org files without needing Emacs.
  2. Everything tool. Org Mode is a planner with an agenda, a to-do list, a note-taking app, a Jupyter Notebook-like tool, and a zettelkasten tool. You can manage almost every aspect of your life with Org Mode.
  3. The only way to eat an elephant is piece by piece. I do not have a habit of collecting and keeping information. I believe that discovering other aspects of Org Mode will lead me to better note-taking practices.

Render Org to blog or whatever

Org already has a way to render files into HTML, allowing you to create simple HTML files with minimal styling. I'm not interesting in styling from org, so I decide to use picocss framework.

Render HTML

I want to change some templates here and there. I've found esxml package. It is a decent DSL for writing XML/HTML. Here is how page header and footer look in this DSL.

(defun my/header (info)
  `(header (@ (class "header"))
    (nav
     (ul
      (li
       (strong
	,(org-export-data (plist-get info :title) info))))
     (ul
      (li (a (@ (href "/index.html")) "About"))
      (li (a (@ (href "/blog.html")) "Blog"))
      (li (a (@ (href "/rss.xml")) "RSS"))
      )
     ))
  )

(defun my/footer (info)
  `(footer (@ (class "footer"))
    (hr)
    (p "Alex Mikhailov")
    (p "Built with: "
       (a (@ (href "https://www.gnu.org/software/emacs/")) "GNU Emacs") " "
       (a (@ (href "https://orgmode.org/")) "Org Mode") " "
       (a (@ (href "https://picocss.com/")) "picocss")
       )
    ))

Looks neat for me. At least I don't need to mess with string concatenation. Whole template wiring looks like that. Not much, but it works and easy to maintain.

(defun my/template (contents info)
  (concat
   "<!DOCTYPE html>"
   (sxml-to-xml
    `(html (@ (lang "en"))
      (head
       (meta (@ (charset "utf-8")))
       (meta (@ (author "Alex Mikhailov")))
       (meta (@ (name "viewport")
		(content "width=device-width, initial-scale=1, shrink-to-fit=no")))
       (meta (@ (name "color-scheme") (content "light dark")))
       (meta (@ (http-equiv "content-language") (content "en-us")))
       (meta (@ (name "description") (content "Personal page with a blog about my technical adventures")))
       (link (@ (rel "icon") (type "image/x-icon") (href "/resources/favicon.ico")))
       (link (@ (rel "stylesheet") (href "/resources/css/pico.sand.min.css")))

       (script (@ (defer "true") (src "https://umami.dokutsu.xyz/script.js") (data-website-id "d52d9af1-0c7d-4531-84c6-0b9c2850011f")) ())
       (title ,(org-export-data (plist-get info :title) info)))

      (body
       (main (@ (class "container"))
	     ,(my/header info)
	     (*RAW-STRING* ,contents)
	     ,(my/footer info)
	     )
       ))
    ))
  )

Ok, now we need some additional steps to wire these templating function.

;; Derive new backend with our custom tepmplating function
;; We derive it from regular HTML backend

(org-export-define-derived-backend 'my-html 'html
  :translate-alist '((template . my/template)
		     ))

;; Define publish function which uses our freshly derived backend
(defun my/publish-to-html (plist filename pub-dir)
  "Publish an Org file to HTML using the custom backend."
  (org-publish-org-to 'my-html filename ".html" plist pub-dir))

So everything is almost done. Time to use our custom publishing function in projects list.

(setq org-publish-project-alist
      (list
       (list "blog"
	     :recursive t
	     :base-directory my/blog-src-path
	     :publishing-directory my/web-export-path
	     :publishing-function 'my/publish-to-html
	     :html-html5-fancy t
	     :htmlized-source t
	     :with-author nil
	     :with-creator t
	     :with-toc t
	     :section-numbers nil
	     :time-stamp-file nil
	     )
       ))

Static files

Yep, you may want to publish some photos with your blog or any other static files.

(setq org-publish-project-alist
      (list
       (list "static"
	     :base-directory my/blog-src-path
	     :base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|pdf\\|ico\\|txt"
	     :publishing-directory my/web-export-path
	     :recursive t
	     :publishing-function 'org-publish-attachment
	     )
      ))

Looks self explanatory.

Whole build script

Here is the whole elisp script which I use to publish my blog. It have some additional quirks to work with doomscript ./build-site.el.

;; Load the publishing system
;; Configure environment
;;
(setq debug-on-error t)

(let ((default-directory  (concat "~/.config/emacs/.local/straight/build-" emacs-version "/")))
  (normal-top-level-add-subdirs-to-load-path))

(add-to-list 'custom-theme-load-path
	     (concat "~/.config/emacs/.local/straight/build-" emacs-version "/doom-themes"))
(add-to-list 'custom-theme-load-path (concat "~/.config/emacs/.local/straight/build-" emacs-version "/base16-theme"))
(add-to-list 'custom-theme-load-path (concat "~/.config/emacs/.local/straight/build-" emacs-version "/moe-theme"))


(require 'xml)
(require 'dom)
(require 'ox-publish)
(require 'ox-rss)
(require 'org)
(require 'esxml)

;;
;;Variables
;;
(setq
 my/url "https://fidonode.me"
 my/web-export-path "./public"
 my/blog-src-path "./home/05 Blog"
 org-html-validation-link nil            ;; Don't show validation link
 org-html-htmlize-output-type 'inline-css
 org-src-fontify-natively t)

;;
;;Templates
;;
(defun my/footer (info)
  `(footer (@ (class "footer"))
    (hr)
    (p "Alex Mikhailov")
    (p "Built with: "
       (a (@ (href "https://www.gnu.org/software/emacs/")) "GNU Emacs") " "
       (a (@ (href "https://orgmode.org/")) "Org Mode") " "
       (a (@ (href "https://picocss.com/")) "picocss")
       )
    ))

(defun my/header (info)
  `(header (@ (class "header"))
    (nav
     (ul
      (li
       (strong
	,(org-export-data (plist-get info :title) info))))
     (ul
      (li (a (@ (href "/index.html")) "About"))
      (li (a (@ (href "/blog.html")) "Blog"))
      (li (a (@ (href "/rss.xml")) "RSS"))
      )
     ))
  )

(defun my/template (contents info)
  (concat
   "<!DOCTYPE html>"
   (sxml-to-xml
    `(html (@ (lang "en"))
      (head
       (meta (@ (charset "utf-8")))
       (meta (@ (author "Alex Mikhailov")))
       (meta (@ (name "viewport")
		(content "width=device-width, initial-scale=1, shrink-to-fit=no")))
       (meta (@ (name "color-scheme") (content "light dark")))
       (meta (@ (http-equiv "content-language") (content "en-us")))
       (meta (@ (name "description") (content "Personal page with a blog about my technical adventures")))
       (link (@ (rel "icon") (type "image/x-icon") (href "/resources/favicon.ico")))
       (link (@ (rel "stylesheet") (href "/resources/css/pico.sand.min.css")))

       (script (@ (defer "true") (src "https://umami.dokutsu.xyz/script.js") (data-website-id "d52d9af1-0c7d-4531-84c6-0b9c2850011f")) ())
       (title ,(org-export-data (plist-get info :title) info)))

      (body
       (main (@ (class "container"))
	     ,(my/header info)
	     (*RAW-STRING* ,contents)
	     ,(my/footer info)
	     )
       ))
    ))
  )


(org-export-define-derived-backend 'my-html 'html
  :translate-alist '((template . my/template)
		     ))

(defun my/publish-to-html (plist filename pub-dir)
  "Publish an Org file to HTML using the custom backend."
  (org-publish-org-to 'my-html filename ".html" plist pub-dir))

;;
;;Helpers
;;
(defun my/format-date-subtitle (file project)
  "Format the date found in FILE of PROJECT."
  (format-time-string "posted on %Y-%m-%d" (org-publish-find-date file project)))



;;
;;Clear folder with results
;;
(when (file-directory-p my/web-export-path)
  (delete-directory my/web-export-path t))
(mkdir my/web-export-path)


;;
;;Main blog configuration
;;
(setq org-publish-project-alist
      (list
       (list "static"
	     :base-directory my/blog-src-path
	     :base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|pdf\\|ico\\|txt"
	     :publishing-directory my/web-export-path
	     :recursive t
	     :publishing-function 'org-publish-attachment
	     )
       (list "blog"
	     :recursive t
	     :base-directory my/blog-src-path
	     :publishing-directory my/web-export-path
	     :publishing-function 'my/publish-to-html
	     :html-html5-fancy t
	     :htmlized-source t
	     :with-author nil
	     :with-creator t
	     :with-toc t
	     :section-numbers nil
	     :time-stamp-file nil
	     )
       ))


;; Generate the site output
(org-publish-all t)

(message "Build complete!")

Publish through GitHub Action

With all previous preparations, this step sounds simple like: emacs -Q --script ./build-site.el I've chosen a pretty standard way to publish static sites through GitHub Pages. Since I keep my Org files in a private repo, I need some additional steps to address it. I use the peaceiris/actions-gh-pages@v3 action to publish from my Org repo to the Pages repo. However, since I use Doom Emacs as my configuration framework, we need to address some more problems.

Install Emacs

If you want to run Emacs Lisp, you need the whole Emacs, at least without GUI. In a GitHub Action, you can simply run:

sudo apt install emacs-nox --yes

This way has a downside - you will install Emacs on each action run since the system state is disposable.

Just bring everything

I need to take extra steps since I use Doom Emacs and have my configs in Org. You may also need to install dependencies for your configuration.

Fetch doom guts

git clone --depth 1 https://github.com/doomemacs/doomemacs ~/.config/emacs

Prepare minimal config for rendering Org file to config.

echo '(doom! :config literate)' > ~/.config/doom/init.el
echo '(setq +literate-config-file "'$(pwd)'/config/config.org")' > ~/.config/doom/cli.el

Finally, install all dependencies according to my config. Yes, it is overhead, but I can be sure that I have the same dependencies as on my dev machine.

~/.config/emacs/bin/doom sync -B

Of course, I use a caching step to make the whole process faster:

- name: Cache doom-emacs
  uses: actions/cache@v4
  id: cache-doom-save
  with:
    path: ~/.config/emacs
    key: ${{ runner.os }}-doom

BTW I use GNU Emacs

Here's the whole publishing workflow.

name: pages
on:
  push:
    branches:
      - "main"
    # Do not trigger build on changes in other folders
    paths-ignore:
      - "./home/02 Action"
      - "./home/03 PKM"
      - "./home/04 Log"
      - "./home/06 Projects"
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out
	uses: actions/checkout@v1

	#Install emacs without GUI components
      - name: Install Emacs
	run: sudo apt install emacs-nox --yes

	#Clone doomemacs. Yep, always the most fresh master. Let it fire.
      - name: Install doom
	run: git clone --depth 1 https://github.com/doomemacs/doomemacs ~/.config/emacs

	# Use cached files to shave some time
      - name: Restore cached doom-emacs
	id: cache-doom-restore
	uses: actions/cache/restore@v4
	with:
	  path: ~/.config/emacs
	  key: ${{ runner.os }}-doom

      - name: Create folder
	run: mkdir -p ~/.config/doom/

	# I use literate config, so we need some extra steps to botstrap my config
      - name: Put template for literate config
	run: echo '(doom! :config literate)' > ~/.config/doom/init.el

	# Yep. I also keep my emacs config in org in my org repo
      - name: Propagate org conf
	run: echo '(setq +literate-config-file "'$(pwd)'/config/config.org")' > ~/.config/doom/cli.el

	# Build doomemacs deps. Should be relativelly fast, cause almost everything cached.
      - name: Sync doom
	run: ~/.config/emacs/bin/doom sync -B

	#Put files into cache
      - name: Cache doom-emacs
	uses: actions/cache@v4
	id: cache-doom-save
	with:
	  path: ~/.config/emacs
	  key: ${{ runner.os }}-doom

      - name: Build the site
	run: ~/.config/emacs/bin/doomscript ./build-site.el

	# Deploy from this repo to that ~external_repository~
      - name: Deploy
	uses: peaceiris/actions-gh-pages@v3
	with:
	  deploy_key: ${{ secrets.PRIVATE_KEY }}
	  external_repository: fido-node/fido-node.github.io
	  publish_branch: gh-pages
	  publish_dir: ./public

What is next

I have a plans to make posts about next features: