skywhi@dreamland:/var/log$ _


Hello World from Org

First published: 25 May 2025

I describe yet-another-simple-publishing-setup for a static website using Emacs's Org mode.

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

Rationale

I figured it was finally the time to host some of my 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 the files integrate well with the agenda and other nifty features Org mode has to offer. The ability to hack its behavior directly in elisp is also a great plus to me. This is why I searched for options that would allow me to use my Org content without too much trouble.

When it comes to this website, I want to be able to turn what I am currently working on into publishable content without having to spend extra time on re-formatting my writing completely. 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 simple HTML files. There a few other projects which turn a collection of Org files into a website, such as org-static-blog, but I figured I would like to have maximal control over the toolchain and keep external dependencies as low as possible.

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 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 source files can be found here.

- ~/org/blog/
  - files/
    - 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 standalone Org files that will be published to HTML. They 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 directly in the files themselves. The files/ and templates/ directories host assets such as stylesheets, fonts, images, etc. that are specific to the HTML export.

The logic behind publishing is implemented in publish-website.el which is then called by a Makefile for convenience. It defines all the directories, files and exporting options to be fed into org-publish. I could have written 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 I can.

To the first page!

Source files

Global exporting options are defined in publish-website.el which can be overridden by the definitions in the single Org files. When several Org files share the same defaults, it can make sense to define them in an Org template file and use the #+SETUPFILE: ../templates/mytemplate.org directive in the relevant source files. #+INCLUDE: <path/to/file.org> can also be used, its content will then plainly be inserted in the source file before processing.

This is for example the beginning of this post. I set the title, the date, I include a template and insert a preview. More on all this a little bit later.

#+TITLE: Hello World from Org
#+DATE: [2025-05-25 Sun]
#+INCLUDE: ../../templates/post.org

#+begin_preview
I describe yet-another-simple-publishing-setup for a static website using
Emacs's Org mode.
#+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

#+TOC: headlines 3

* Rationale
:PROPERTIES:
:CUSTOM_ID: rationale
:END:

I figured it was finally the time to host some of my 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 to headlines makes it possible to link to the specific headline in the table of contents and HTML.

Templates

Template are used to group seveal Org rendering and exporting that are common to several files. For example, the template under templates/post.org looks like this:

@@html:<div class="post-date">@@
/First published: {{{date(%d %b %Y)}}}/
@@html:</div>@@

The date tag of the post will be extracted and added to the HTML during publishing.

Here is a template for the ReadTheOrg theme by Fabrice Niessen that inserts HTML into any page published with it:

#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="/files/css/readtheorg/readtheorg.css"/>
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="/files/css/readtheorg/htmlize.css"/>
#+HTML_HEAD_EXTRA:
#+OPTIONS: html-preamble:nil
#+OPTIONS: html-postamble:nil

CSS styling

The two files that are used are files/css/style.css and files/css/htmlize.css. In style.css are all the base styles for the pages defined. 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 exporting options. htmlize.css is used to pretty source code blocks.

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.

;; Extract content between #+begin_preview and #+end_preview
(defun skw-blog/get-preview (file)
  "The comments (begin/end_preview) in'file' 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"
              (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>@@")))

The corresponding publishing rules then look like this:

("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:

#+INCLUDE: "./posts/index-no-description.org" :lines "3-7"

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
    make mathjax
    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. 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:

Top of the page - Sitemap -