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?
- Plain text. Plain text as a data source offers significant versatility. You can read and understand what happens in org files without needing Emacs.
- 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.
- 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: