Hello World from Org
I describe yet-another-simple-publishing-setup for a static website using Emacs's Org mode. While there are plenty of resources on the topic available already, I wanted to document my setup here for self-reference. Maybe someone will take away an idea or two.
skywhi@dreamland:~/blog$ echo "Hello World Wild Web!" > posts/hello.org skywhi@dreamland:~/blog$ make update && make publish skywhi@dreamland:~/blog$ # make blogging great again!
Table of Contents:
Table of Contents
Rationale
I figured the time had come to host some of my modest content online for sharing, fun, persistence, easy access and all the other usual suspects. Going into this adventure there were only a few requirements that I wanted my website to meet:
- Content should be written in my favorite text editor with Org mode.
- No need for any back-end or front-end logic: serving a static website without any fancy HTML/CSS/JS shenanigans will be more than fine.
- The website should be standalone so that it can be viewed offline as well.
- Convenient hosting: hustling with administrating a web-server for such a small project would not make any sense to me.
Let me explain. I have been using Emacs and Org mode for several years now. Its markup language really fits my needs and I have integrated my Org files with the agenda and other nifty features Emacs and Org have to offer. The ability to hack their behavior in elisp is also a great plus to me as it immensely increases the flexibility of this setup.
When it comes to this website, I want to be able to turn whatever I am currently working on into publishable content without having to spend extra time on re-formatting my writing from the ground up. It was thus pretty obvious that I would give a try to the built-in org-publish package for Emacs as it is exactly what I am looking for: automated and configurable exporting of Org projects to HTML files, as plain as possible. There exist quite a few other projects which turn a collection of Org files into a website, such as Hugo or org-static-blog. But I figured I would like to retain maximal control over the exporting process and keep external dependencies as low as possible so I stuck to org-publish.
Using git versioning gives me the opportunity to track and manage changes over time. Since we are dealing with a static website with exclusively public data, github.io will handle the hosting for me. Whenever I want to work on a new item, I can pull the remote github repository, create a local branch and start editing. When the content is ready for publishing, the branch is merged into main, pushed to the remote github repository and voilĂ !
Project layout
I ended up with something like the following directory structure for this project. The github repository containing all the up-to-date source files can be found here.
- ~/org/blog/
- static/
- css/
- fonts/
- img/
- src/
- index.org
- about.org
- posts/
- hello.org
- super-dupa-post.org
- templates/
- header.html
- footer.html
- post.org
- readthedocs.org
- Makefile
- publish-website.el
- README.org
src/ contains the Org files that will be published to HTML. These files are declaring as few exporting options as possible so that they can easily be used in other contexts or projects. Global exporting options will be defined in publish-website.el and page-specific options provided via templates/ or, in rare cases, directly in the files themselves. The static/ directory hosts assets such as stylesheets, fonts, images, etc. that will be exported "as is".
The logic behind publishing is implemented in publish-website.el which is called by a Makefile for convenience. It defines all the directories, files and exporting options that will be fed into org-publish. I could have written my publish-website.el in literate programming style and use it as source for this blog post, but that would have been a little bit too meta, even for me :)
Finally, README.org is just a symbolic link to src/about.org. Why? Because we can!
To the first page!
Source files
This is for example the beginning of this post. It does not contain much beside
a title, a date, a template and a table of content. There is also a custom
preview block, but more on that a little bit later.
#+TITLE: Hello World from Org #+DATE: [2025-05-25 Sun] #+begin_preview I describe /yet-another-simple-publishing-setup/ for a static website using Emacs's Org mode. While there are plenty of resources on the topic available already, I wanted to document my setup here for self-reference. Maybe someone will take away a tip or two. #+end_preview #+begin_src sh skywhi@dreamland:~/blog$ echo "Hello World Wild Web!" > posts/hello.org skywhi@dreamland:~/blog$ make update && make publish skywhi@dreamland:~/blog$ # make blogging great again! #+end_src *Table of Contents:* * Rationale :PROPERTIES: :CUSTOM_ID: rationale :END: I figured the time had come to host some of my modest content online for sharing, fun, persistence, easy access and all the other usual suspects. Going into this adventure there were only a few requirements that I wanted my website to meet: [...]
Appending a CUSTOM_ID property (C-c C-x p) to headings makes it possible to
link to them in the HTML. This example link points to the present section.
Templates
When several Org files share the same rendering and exporting options, it can
make sense to regroup them in an Org template file and use the #+SETUPFILE or
#+INCLUDE directive in the relevant source files.
For example, the template under templates/post.org will extract the date
directive of the source file and add custom HTML to the post.
@@html:<div class="post-date">@@ /First published: {{{date(%d %b %Y)}}}/ @@html:</div>@@
Here is a template for the ReadTheOrg theme by Fabrice Niessen that inserts the correct CSS styling into any page published with it:
CSS styling
The two files that are used are style.css and htmlize.css. The first hosts all
the base styles for the website while the second prettifies source code blocks
by redefining various .org-* CSS classes. Since I am aiming for a small and
flexible website I decided to write my own CSS manually (and I suck at it tbh).
People who don't want to enjoy the same painful wonderful experience should
try to look for pre-existing stylesheets and nice exporting options.
Building and publishing
publish-website.el
It was hyped for long enough, here is the publish-website.el file. This file defines common settings and build instructions for the project. We feed them into org-publish which will take care of producing the corresponding website.
The file is pretty simple and self-explanatory. I mostly added the following
functions to generate a "preview" of each post, which will be visible in the
list of all posts, as well as include a formatted timestamp of when the post was
first added. A custom macro is used to format the timestamp. The content of the
"preview" is written in between the custom #+begin_preview and #+end_preview
tags.
;; Preview block (defun skw-blog/get-preview (file) "Extract the content between #+begin_preview and #+end_preview blocks in 'file'. The block tags have to be on their own lines, preferably before and after paragraphs." (with-temp-buffer (message file) (insert-file-contents file) (goto-char (point-min)) (let ((beg (+ 1 (re-search-forward "^#\\+begin_preview$"))) (end (progn (re-search-forward "^#\\+end_preview$") (match-beginning 0)))) (replace-regexp-in-string "\n" " " (buffer-substring beg end))))) ;; Format list of blog post for the sitemap / index (defun skw-blog/org-format-blog-post (entry style project) "Format 'entry' in org-publish 'project' sitemap to include a timestamp." (let ((entry-title (org-publish-find-title entry project))) (if (= (length entry-title) 0) (format "*%s*" entry) (format "{{{timestamp(%s)}}}: [[file:%s][%s]]" (format-time-string "%Y-%m-%d" (org-publish-find-date entry project)) entry entry-title)))) ;; Same but add the content between the "preview" tags (defun skw-blog/org-format-blog-post-with-preview (entry style project) "Format 'entry' in org-publish 'project' sitemap to include a timestamp and preview ('begin/end_preview' tag)." (let ((entry-title (org-publish-find-title entry project)) (preview (skw-blog/get-preview (concat (skw-blog/get-root-directory) "src/posts/" entry)))) ;; dirty (if (= (length entry-title) 0) (format "*%s*" entry) (format "{{{timestamp(%s)}}}: [[file:%s][%s]]\n %s {{{br}}}" (format-time-string "%Y-%m-%d" (org-publish-find-date entry project)) entry entry-title preview)))) ;; Exporting macros (setq org-export-global-macros '(("timestamp" . "@@html:<span class=\"timestamp\">$1</span>@@") ("br" . "@@html:</br></br>@@")))
The following rules publish the Org file and generate 2 index files: posts/index.org and posts/index-no-preview.org. The first one containing previews of all posts and the second one with just the post title.
("website-src" :base-directory ,skw-blog/srcdir :base-extension "org" :exclude ,(regexp-opt '("rss.org")) :recursive t :publishing-directory ,skw-blog/outdir :publishing-function org-html-publish-to-html :auto-sitemap t :sitemap-title ,skw-blog/main-sitemap-title :html-preamble ,skw-blog/header :html-postamble ,skw-blog/footer :html-head ,(concat skw-blog/main-css skw-blog/favicon)) ;; Index of all blog posts ("website-posts-index" :base-directory ,(concat skw-blog/srcdir "posts") :base-extension "org" :exclude ,(regexp-opt '("rss.org" "index.org" "index-no-preview.org")) :publishing-directory ,(concat skw-blog/outdir "posts") :html-preamble ,skw-blog/header :html-postamble ,skw-blog/footer :html-head ,(concat skw-blog/main-css skw-blog/favicon) :auto-sitemap t :sitemap-title "Posts (without preview)" :sitemap-filename "index-no-preview.org" :sitemap-format-entry skw-blog/org-format-blog-post :sitemap-sort-files anti-chronologically) ;; Index of all blog posts with preview ("website-posts-index-preview" :base-directory ,(concat skw-blog/srcdir "posts") :base-extension "org" :exclude ,(regexp-opt '("rss.org" "index.org" "index-no-preview.org")) :publishing-directory ,(concat skw-blog/outdir "posts") :html-preamble ,skw-blog/header :html-postamble ,skw-blog/footer :html-head ,(concat skw-blog/main-css skw-blog/favicon) :auto-sitemap t :sitemap-title "Posts" :sitemap-filename "index.org" :sitemap-format-entry skw-blog/org-format-blog-post-with-preview :sitemap-sort-files anti-chronologically)
The generated src/posts/index-no-description.org is inserted into the homepage
with :lines "3-7" in order to display only the last 5 most recent posts.
Makefile
Let's use make to avoid building and managing the website by hand.
OUT_DIR='www' PUBLISH_FILE='publish-website.el' PUBLISH_FUNC='(org-publish "website")' WS_CMD=python -m http.server 12345 --bind localhost --directory $(OUT_DIR) all: rm -rf .cache www emacs -Q --batch --load $(PUBLISH_FILE) --eval $(PUBLISH_FUNC) rm -rf .cache publish: git commit git push -u origin main update: make all git add . git status run: $(WS_CMD) .PHONY: all mathjax publish update run
Whenever I want to work on a new item, like adding a new file or editing an
existing one, I switch to a new local branch in the git repository. I then
simply run make periodically while editing to visualize my changes. When I am
done, I merge the branch into my local main branch and run make update to
verify what will be committed and then make publish to push the changes on the
remote repository. Cherry on top, make run spawns a python web-server to view
the website locally at http://localhost:12345.
A few more functionalities
MathJax
MathJax' Org mode support lets me export to HTML math formulas written in LaTeX. Here are a couple of examples using inline code blocks and "proper" LaTeX syntax.
If $a^2=b$ and \( b=2 \), then the solution must be either $$ a=+\sqrt{2} $$ or
\[ a=-\sqrt{2} \]
Look at this lonely equation I found:
\begin{equation}
y = x^2+1
\end{equation}
Which will be rendered like this:
If \(a^2=b\) and \( b=2 \), then the solution must be either \[ a=+\sqrt{2} \] or \[ a=-\sqrt{2} \]
Look at this lonely equation I found:
\begin{equation} y = x^2+1 \end{equation}
I install and update regularly MathJax via the Makefile and then enable it
in publish-website.el via the org-html-mathjax-options variable. Since I
want to be able to view the website offline, I opted for a local install.
mathjax: rm -rf files/js/mathjax git clone https://github.com/mathjax/MathJax.git files/js/mathjax rm -rf files/js/mathjax/.git
(setq org-html-mathjax-options '((path "/files/js/mathjax/es5/tex-mml-chtml.js") (scale 1.0) (align "center") (font "mathjax-modern") (overflow "overflow") (tags "ams") (indent "0em") (multlinewidth "85%") (tagindent ".8em") (tagside "right")))
RSS feed
Generating a RSS to track when new posts are published is actually not that
complicated with ox-rss. Here are a couple of helper functions to generate a
"sitemap" file named rss.org which will list all the entries under
src/posts/ excluding the previously generated indexes. This file is then
converted to XML. The description field of each item is the content of the
preview tags used earlier.
;; RSS feed generation (defun skw-blog/publish-to-rss (plist filename dir) "Publish 'plist' when 'filename' corresponds to RSS feed Org-file to 'dir'." (if (equal skw-blog/rss-filename (file-name-nondirectory filename)) (org-rss-publish-to-rss plist filename dir))) (defun skw-blog/format-rss-feed (title list) "Generate a sitemap of posts that will be exported as a RSS feed. 'title' is title of the RSS feed and 'list' the files to be included." (concat "#+TITLE: " title "\n\n" (org-list-to-subtree list))) (defun skw-blog/format-rss-feed-entry (entry style project) "Format 'entry' for the posts RSS feed in given 'project'." (let* ((title (org-publish-find-title entry project)) (link (concat (file-name-sans-extension entry) ".html")) (pubdate (format-time-string (car org-time-stamp-formats) (org-publish-find-date entry project))) (preview (skw-blog/get-preview (concat (skw-blog/get-root-directory) "src/posts/" entry)))) (format "%s :properties: :rss_permalink: %s :pubdate: %s :end: %s" title link pubdate preview)))
These functions are then called inside a new publishing rule.
("website-rss" :base-directory ,(concat skw-blog/srcdir "posts") :base-extension "org" :recursive nil :exclude ,(regexp-opt '("rss.org" "index.org" "index-no-preview.org")) :publishing-directory ,skw-blog/outdir :publishing-function skw-blog/publish-to-rss :with-author t :author ,skw-blog/author :email ,skw-blog/email :rss-extension "xml" :rss-image-url ,(concat skw-blog/upstream-url "/files/img/profile.jpg") :html-link-home ,(concat skw-blog/upstream-url "/posts/") :html-link-use-abs-url t :html-link-org-files-as-html t :auto-sitemap t :sitemap-filename ,skw-blog/rss-filename :sitemap-title ,skw-blog/rss-feedname :sitemap-sort-files anti-chronologically :sitemap-function skw-blog/format-rss-feed :sitemap-format-entry skw-blog/format-rss-feed-entry)
Resources
This publishing setup is quite rudimentary. I wrote this note mostly for self reference in case I have to implement a similar project in the future. However, I still hope it can help hesitating people to give a try to org-publish and see if it fits their needs. Maybe existing users could also get a tip or two out of this. The full code is available on my github.
I would encourage anyone fiddling with this to have the official documentation right at hand:
Here are websites that inspired me for this adventure. I highly recommend that you check them out as well as their publishing workflow:
- Building a Emacs Org-Mode Blog by Thomas Ingram showcases a simple yet effective setup.
- Blogging using org-mode (and nothing else) by Dennis Ogbe is a really neat explanations and setup with Org-file pre-processing before publishing.
- A literate programming example by ryuslash.
Edit : Updated directory structure and rephrased some sections.