YAOB – Yet Another Org Blog
A Static Site Generator for Emacs Org Mode
This page provides an overview of yaob (Yet Another Org Blog), a static site generator built on top of Org Mode’s exporting and publishing features, namely ox-html.
Code is at https://github.com/bcardoso/yaob.
This blog is generated with yaob.
1. Motivation #
I have been hacking with Org Mode’s ox-html package for quite some time now. At first, trying to wrap my head around its publishing workflow; then, trying to steer it towards the output I wanted to achieve.
I ended up doing what one often does: writing a specific configuration that slowly evolves towards a static site generator on its own.
In fact, yaob is the product of having two main itches to scratch:
- I wanted a blog with a tag index, and a page for each tag’s posts.
- I was growing unhappy with the dozens of variables I had to define to produce the desired output, so I wanted to be able to simply declare the main blog components and build upon them.
After lots of tinkering and many détournements, the outcome is this package and this blog. It may be a bit over-engineered, but that was the most fun part.
No LLMs were used. Bugs are my own.
2. Features #
Here are some of yaob’s main features, in order to provide a comprehensive overview of the package. For more detailed explanations, please refer to the code’s docstrings, where I tried to document things more thoroughly.
2.1. Declarative configuration #
In yaob, a “blog” is an instance of the yaob-blog class.
As an example, the following code is the actual entire definition of this blog:
;; -*- lexical-binding: t; -*-
(require 'yaob)
(defvar interzone
(yaob-blog
:name "interzone"
:path "~/projects/interzone/blog"
:public-path "~/projects/interzone/public_html"
:projects '(posts pages static)
:tags '(posts . "tags")
:rss 'posts
:title "(interzone)"
:description "Emacs Lisp Magick"
:url "https://tilde.town/~cryptk/blog/"
:image "static/interzone.png"
:head-tags '((css :href "static/modern-normalize.css")
(css :href "static/style.css")
(icon :href "static/favicon.ico")
(rss :href "posts/rss.xml" :title "RSS 2.0")
(fedi :name "fediverse:creator"
:content "@cryptk@mastodon.social")
(color :name "color-scheme" :content "dark"))
:nav-menu '(("Home" . "index.html")
("Posts" . "posts/")
("Tags" . "tags/")
("Now" . "now.html")
("About" . "about.html")
("RSS" . "posts/rss.xml"))
:sitemap-style 'title-subtitle-date-tags
:translation-overrides '(("Table of Contents" . "Contents")
("Footnotes" . "Notes"))
:exclude-tags '("noexport" "draft")))
I then tangle the Org source block above into a file named interzone.el in my Emacs load path.
In short, :name is the blog identifier, :path is the blog’s base directory, and :public-path is the directory for the generated HTML files.
Slot :projects is a list of project identifiers that are translated into publishing projects. Along with :title, those are the only required slots for a blog instance. All other slots are optional.
Refer to yaob-blog class definition for all the available slots.
The current project structure for this blog is as follows, with the auto-generated “tags” directory:
~/projects/interzone/
├── blog
│ ├── index.org
│ ├── posts/
│ ├── static/
│ ├── tags/
│ └── yaob.org
└── public_html
├── index.html
├── posts/
├── static/
├── tags/
└── yaob.html
The publishing projects for :projects are built by the yaob-blog-project function. There already are methods for the “posts” directory (with a sitemap specification), and “static” (for all non-Org files in any directory).
In the example above, “pages” is an undefined project identifier, but it’s also the one responsible for publishing the files at the base directory, such as “index.org” and “yaob.org”. Any undefined project will fallback to a basic project definition, unless you define it yourself. See Defining new projects.
Function yaob-blog-projects-alist is the one responsible for compiling all the blog’s projects into a proper alist for org-publish.
2.3. Entry templates #
The variable yaob-blog-entry-templates defines templates for input files whose path matches a given regexp. This may be modified directly or overridden by the value of the :entry-templates slot.
;; Default value of `yaob-blog-entry-templates'
'(("posts/" . (title author-date contents tags))
(".*" . (title date contents)))
Refer to its docstring for a more detailed explanation.
In short, this allows for having different HTML constructs for different types of pages. For instance, files under the “posts” directory may show the author and date information, along with their tags, while other files may not.
2.4. Custom headline functions #
I wanted to have a special HTML structure for the Now page. On that page, top-level headings are converted by a function named yaob-html-headline-journal.
Any Org file may define its own headline style by setting the custom file property HTML_HEADLINE_FN to a function name.
See the docstring of yaob-html-headline for more details.
2.5. RSS feeds #
Just as with tags, this is an optional feature that only triggers when :rss is defined as one of :projects names.
In the example provided earlier, the :rss slot value is 'posts, but it can also be a list of project identifiers.
All the heavy-lifting is actually done by the great org-publish-rss package.
For every project in :rss, the required properties are added to their project’s specification. Likewise, the projects responsible for copying the RSS files to their corresponding public locations are auto-included in the final blog’s projects alist. Hence, the only configuration needed is to set :rss to the appropriated value.
For this blog, the resulting rss.xml is created under the “posts” subdirectory.
3. Dependencies #
Besides obviously depending on Org Mode’s exporting features, this project has four external dependencies, three of them optional:
- tali713/esxml (Required): An elisp library for working with xml, esxml and sxml. This is used by the
yaob-xml.ellibrary to build the HTML blocks. - emacsorphanage/htmlize (Optional): To convert buffer text and decorations to HTML, which is an optional dependency of
ox-htmlitself. - ~taingram/org-publish-rss (Optional-ish): To automatically generate RSS feeds for
org-publishprojects. As a matter of fact, it’s only optional as long as there is no need for a RSS feed, but required otherwise. - skeeto/emacs-web-server aka
simple-httpd(Optional): An Emacs HTTP 1.1 server. This one is just for convenience, to make it easier to preview the blog locally. It’s only relevant for theyaob-servefunction.
4. Installation #
Assuming MELPA is already configured and use-package is the preferred loading method:
(use-package esxml)
(use-package htmlize)
(use-package simple-httpd)
(use-package org-publish-rss
:vc (:url "https://git.sr.ht/~taingram/org-publish-rss"))
(use-package yaob
:vc (:url "https://github.com/bcardoso/yaob"))
The yaob package consists of three files:
- yaob.el
- The custom HTML export backend.
- yaob-blog.el
- Blog class definition and projects alist building.
- yaob-xml.el
- A helper library for building the HTML blocks.
5. Usage #
First of all, a blog instance must be defined:
(require 'yaob)
(defvar my/blog
(yaob-blog :name "blog"
:path "~/projects/my-blog/blog"
:public-path "~/projects/my-blog/public_html"
:projects '(posts pages static)
:title "My Blog")
"The minimal requirements for a blog.")
Then, you have to create the relevant directories (in this case, only “posts”, as “pages” and “static” will refer to the blog’s base directory) and write your own Org files.
See M-x customize-group RET yaob for other options.
5.1. Publishing #
The main entry point is yaob-publish, which takes the blog instance and, optionally, a project name. It then compiles the blog’s project alist and runs org-publish.
;; Publish blog
(yaob-publish my/blog)
;; Publish only the "posts" project
(yaob-publish my/blog "blog-posts")
;; Force publishing all projects
(yaob-publish my/blog nil t)
;; Check the blog's complete projects alist
(yaob-blog-projects-alist my/blog)
;; Locally serve the public directory
(yaob-serve my/blog)
For this blog, I tangle the following Org source block into interzone.el:
(defalias 'interzone-publish
(lambda (&optional force)
(interactive "P")
(yaob-publish interzone nil force))
"Publish blog. When FORCE is non-nil, force publish all files.")
(defalias 'interzone-serve
(lambda () (interactive) (yaob-serve interzone))
"Serve the public directory.")
5.2. Creating files #
One may also want to define commands for creating new files.
Here are some commands I use, which are as well tangled into my interzone.el file (replace “interzone” with your own blog variable name):
(defalias 'interzone-new-post
(lambda () (interactive) (yaob-new-file interzone 'posts))
"Create a new post.")
(defalias 'interzone-new-page
(lambda () (interactive) (yaob-new-file interzone 'pages))
"Create a new page.")
(defalias 'interzone-new-journal
(lambda ()
(interactive)
(let ((org-capture-templates
(yaob-org-capture-template interzone 'pages "now.org"
:prepend t :empty-lines 1 :jump-to-captured t)))
(org-capture nil "J")))
"Capture a new journal entry.")
5.3. Defining new projects #
In org-publish, a “project” is a publishing specification for a directory.
By default, yaob assumes that any directory contains Org files to be converted to HTML, and uses a basic project specification for them (see function yaob-blog-project-basic).
If you don’t specify a base or a publishing directory, it defaults to the blog’s :path and :public-path. That is the case, for this blog, of the pages project, which has no definition and, as such, falls back to the defaults.
Now say, for instance, you have added “notes” and “images” to your blog’s
:projects, and you want to publish them is specific ways.
Your “notes” directory may contain files like “my-draft1.org” or “unfinished_post.org” that you don’t want to make public yet.
You need to tell yaob about this by defining your “notes” project as follows, where “my/blog” is the variable holding your own blog definition:
(yaob-blog-project-define my/blog 'notes
:base-directory "~/projects/my-blog/blog/notes"
:publishing-directory "~/projects/my-blog/public_html/notes"
:exclude-regexp (regexp-opt '("draft" "unfinished")))
;; Alternatively, use the `:dir' keyword as a shorthand for the directories.
;; This produces the same definition as above:
(yaob-blog-project-define my/blog 'notes
:dir "notes"
:exclude-regexp (regexp-opt '("draft" "unfinished")))
For the “images” directory, there is no need to convert anything, but to just move files to their :public-path location. You can define it like this:
(yaob-blog-project-define my/blog 'images
:dir "images"
:recursive t
:base-extension (regexp-opt '("jpg" "jpeg" "gif" "png"))
:publishing-function 'org-publish-attachment)
Now, whenever you run yaob-publish, it will know about those directories and take care of the rest.
See Publishing Org-mode files to HTML for a detailed explanation of all the publishing options.
6. Design considerations #
yaob defines a custom Org export backend, derived from the default HTML backend, named yaob-html-blog.
Whenever possible, I tried to rely directly on ox-html. However, one of my main struggles was that I wanted different behaviors from the ones that are currently hard-coded in it.
Because of that, my goal was to structure yaob to be modular, such that specific parts could be easily extended or overridden.
6.1. Extensibility #
By default, in the project definitions for “posts” and “static”, as well as in any project definition returned by yaob-blog-project-basic, a reference to the current blog instance is added to the info plist as the value of the :yaob-blog property, and most functions depend on this.
yaob-blog-project is a generic function that takes as arguments the blog instance, a project identifier as a symbol, and, optionally, the blog name as symbol.
yaob-blog-project-define is the user facing function for defining new methods based on a blog’s project identifier. In this way, when new projects are defined, function yaob-blog-projects-alist becomes aware of them. That is also how the “tags” and RSS features are enabled conditionally.
yaob-html-head-tag is also a generic function, so the :html-tags slot may be extended with methods for specific tags.
I’m looking forward to improve this extensibility further.
6.2. Editing the defaults #
Most functions may be simply overridden, altered or ignored, as they often return small, composable chunks of HTML.
For example: by default, the generated HTML pages contain specific tags in the <head> section for The Open Graph protocol. If that is not wanted, it can be simply removed:
(remove-hook 'yaob-html-head-functions #'yaob-html-head-opengraph)
Or, if you want to add an element to the header/preamble or footer/postamble sections:
(add-hook 'yaob-html-preamble-functions #'my/html-preamble-function)
(add-hook 'yaob-html-postamble-functions #'my/html-postamble-function)
Note that sections’ elements are composed in order, so you may as well need to rearrange those functions accordingly.
A custom function must take the info plist as argument and return a HTML string.
6.3. No CSS nor building process #
It’s up to the user to define their own CSS styles, so none is included. For this blog, I extracted the default one provided by ox-html (in org-html-style-default) into my own style.css file, which I found to be more convenient to manage and edit. Custom stylesheets such as gongzhitaao/orgcss, fniessen/org-html-themes, Org Themes collection may be interesting to try out.
Likewise, it’s up to the user to implement their own building and publishing processes. Personally, I see little advantage in having a Makefile or in doing some sort of external batch exporting, since I’m always on Emacs anyway. Publishing can be as simple as (yaob-publish my/blog). Anything more involved may as well be written in elisp.
6.4. Limitations #
In effect, yaob shadows some native features of ox-html, either by overriding or by ignoring them. I chose this approach to avoid rewriting some functions almost verbatim, but it may lead to some shortcomings.
You should use ox-html itself if you need anything more complex than a simple a blog.
A known limitation is that the current reference mechanism is not great yet. In ox-html, function org-html--reference creates unique identifiers for many HTML elements. In yaob, this function is overridden in some places by yaob-get-reference, which has a narrower scope and can result in duplicate identifiers.
I also haven’t tested anything related to LaTeX, which I seldom use, so I can’t say it’s properly supported (i.e., maybe I broke it). If it works, it’s only by chance, not by merit.
Finally, unlike the RSS feature, the “tags” feature is restricted to only one project identifier for now. Thus, unless your :tags project’s base directory contains subdirectories and you publish it recursively, there is currently no way of having a “global” tags index for different projects.
7. Try it out #
yaob is at https://github.com/bcardoso/yaob.
As this blog is the first one generated with yaob, edge cases might have been overlooked. Please let me know if you come across any of them.
And, of course, if you happen to use yaob for your own blog, I would love to hear about it.
8. References #
While building yaob, I found many useful resources, blog posts, and tutorials by the Emacs community. Here are some references that, in one way or another, helped me better understand what I was doing and how to implement certain features.
8.1. Org Mode documentation #
8.2. Tutorials and blog posts #
- Build Your Website with Org Mode - System Crafters is a great starting point, see also the systemcrafters-site repo.
- Building a Emacs Org-Mode Blog, by the author of org-publish-rss.
- Improving org-publish workflow for blogging covers filetags extraction and tags pages in a similar fashion.
- Creating a blog also presents a similar strategy for extracting tags and building a tag index.
- Blogging with Emacs and Org presents an interesting workaround to generating a RSS feed with
ox-rss. - Using Emacs and Org-mode as a static site generator showcases a detailed overview of its particular building process.
- Blogs and Wikis with Org (WORG) lists many blogging tools and static site generators based on Org Mode.