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. 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]
#+INCLUDE: ../../templates/post.org

#+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:*
#+TOC: headlines 3

* 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:

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

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.

#+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
    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:

Edit [2026-01-27 Tue]: Updated directory structure and rephrased some sections.

Top of the page - Sitemap -