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: