Static site from Denote with Org
The detours and unexpected turns are often where the real adventure lies.
The why part
Not so long ago (if you live in year 2025) I have finally managed to get hooked on Emacs. This was my 3rd attempt to migrate from VS Code. This time we can call it a success! It can be attributed to System Crafters and particularly Emacs From Scratch series. Bit by bit I got comfortable enough to even start hacking my own little things in Emacs. I went on a lot of tangents including picking up Guile or trying to implement my own Scala source block for Org Babel but the most damaging (to my free time) was my discovery of Denote.
For uninitiated, Denote is a note taking system (but not only) for Emacs, created by Protesilaos Stavrou. I don't know how anyone would land on this page without prior knowledge of Denote, but no shame in that ð. I recommend reading Denote Overview and perhaps some of SystemCrafters videos on YouTube might interest you. And of course Protesilaos talks about Denote himself on his channel.
Even though I have started using Emacs for programming, I think I knew that what I really want from it is a tool to organise my life. I spread myself thin across too many hobbies/projects and tend to have multiple browser windows open containing hundreds of tabs. Now I can file everything in Denote and forget about it till I can allocate time for a particular research. No other personal wiki or brain-mapping software have ever had so much success with me. ðĨģ
Now let's get closer to the actual "why" part for this project. It was SystemCrafters again. I watched a stream on Generating a Blog from Denote entries and thought, that would be cool! The stream didn't end with a complete solution though. So I decided that this can be a nice project to learn more about Emacs/Org programming, and so here we are… months later… ð
Project goals
Given a directory with Denote notes, take a subset of notes meant to be published and generate a static site from them.
If I have learnt anything from the SystemCrafters' stream is that there is unlikely to be a clean integration with Org Export/Publish system. To limit the amount of problems, supporting region/buffer/file exports without setting up an Org Publish1 project is out of scope.
One ASCII tree is worth a thousand words, this is what we are after.
website âââ index.html âââ media â âââ blog_image.png â âââ favicon.png âââ robots.txt âââ styles â âââ code-dark.css â âââ code-light.css â âââ site.css âââ post1 â âââ index.html â âââ plot1.png âââ post2 â âââ index.html âââ post3 âââ index.html
Nothing unexpected, the tricky part is to produce it from Denote notes with Org Publish… To keep things sane, here are the rules for the solution:
- Every file that is to become a page/post, gets its own directory under the site root. Directories are named after the
title
part of the full Denote filename. We achieve two goals with this:- Nice looking, short URLs without any extra HTTP server work. Just check your URL address bar. ð
- Create a place for page-specific resources. I assume pages are going to have dynamically generated data from code blocks. I like to play with Jupyter2 and Julia3 source blocks, so this is a very common scenario for me.
- If a link points to a media file, we will rewrite its URL to point to a shared location identified by a user defined variable (
/media
in the tree above). We need to handle bothdenote:
andfile:
schemes, the first one is obvious, the latter can point to a Denote resource e.g. an inline image. There is an exception though, we can also encounter files coming from the Sitemap4 generator. Therefore, forfile:
scheme the rules are:- Page-generated resource - set a link to be relative to the source page e.g.
./plot1.png
. - Sitemap - we can't know who/what generated a link, but if we detect that it points to a page, generate an absolute URL to a page e.g.
/post1
. - For all other cases act according to
:with-broken-links
5 export setting. Basically, this is an error state, we assume all files in a Denote directory are Denote resources or page-generated files.
- Page-generated resource - set a link to be relative to the source page e.g.
- My Denote files have mostly flat hierarchy. I don't want to have complex mapping rules even though having a bit of nesting certainly helps with the site navigation/structure. I want to keep the project scope under control, to be addressed later. To summarise:
- Assume Denote source directory is flat, at least the parts that are meant to be published.
- Full denote filename is quite long, contains a timestamp and therefore conflicts are unlikely. That is definitely not the case for the page names we have here. If a conflict happens, it is a user error. âĪïļ
Let's roll out an Org Export backend
A quick warm-up hack to start with
I am writing this post after a lot of work has been done already, chronologically, this wasn't my first idea. Back then I simply didn't know enough yet about the internals of Org Export/Publish (and that still stands true). In an attempt to reduce the amount of code after my POC was working already, I thought this might be a path of less code… Spoiler alert - no, turned out this isn't sufficient.
(defun rewrite-denote-link (orig-fn &rest args)
(if (eq (nth 2 args) 'html)
(let* ((link (nth 0 args))
(description (nth 1 args)))
(format-html-denote-link link description))
(apply orig-fn args)))
We can use this function to replace denote-link-ol-export
with advice-add
. As the function name suggest, it is responsible for exporting denote links to various Org Export formats, including HTML. Alternatively, it might be possible to use org-link-set-parameters
and set our override there under :export
keyword, I haven't tried that though.
For some use cases this might be good enough already, but as we are after a whole static site generator, this approach doesn't address files that are linked under file:
scheme. As per the rules above, we do want to handle them.
The format-html-denote-link
function is a simplified version of the Denote's link export function. Instead of calling file-name-sans-extension
that returns all the bits, I call denote-retrieve-filename-title
to get only the title
part.
A proper solution (almost)
The way to achieve what we are after in Org is to define a new export backend6. There are 2 entry points into the process, namely:
org-export-define-backend
- For very brave people who want to support a completely new export format or think they can do everything better (that's a very delicious thought, I must admit).org-export-define-derived-backend
- Lets you take an already existing backend and override some parts of it.
Obviously, HTML backend is already there, so with the derived backend option we can inherit all the existing machinery, do a couple of precise tappy-taps7 and continue with our blogging tasks.
We could also consider a rude approach with advice-add
as we did in the warm-up hack and tap into whatever function generates links in ox-html
8. Fixing links though is unlikely to be the only task for the overarching goal of having a site that not only has functional links generated from Denote files, but also looks like it belongs to the 21st century. So we will need to tweak more parts, not just links. That is for later though. As my Muay Thai coach (one of them) likes to say - "step by step" âĒïļ. So, with patience and dedication let's go the proper way and create a new derived export backend.
The simplest derived backend we can have is:
(org-export-define-derived-backend 'ky-html 'html)
Here we define a custom backend named 'ky-html
that inherits 'html
backend. Not very useful on its own. We are after the :translate-alist
property that we can pass to the function. It should contain a list of cells element/object . transcoder
9, these will replace transcoders of the parent backend. Transcoder is a function that takes an element
or object
and returns a string representation of it. For an HTML backend that will be a string containing HTML.
Transcode Denote links
The function below handles all the link rules we stated above. Just like that, half of the project is done already! ð It took me a month of research to write though! ðĪŠ Org file AST, Org Element API, just regular elisp troubles… 100 open tabs in my browser burning my CPU to ashes…
Anyway, to the function:
(org-export-define-derived-backend 'ky-html 'html
:translate-alist
`((link . ky--publish/html-link)))
(defun ky--publish/html-link (link desc info)
"Link transcoder for custom denote/file link handling.
See `org-html-link' for LINK/DESC/INFO description."
(let* ((link-type (org-element-property :type link))
(link-path (org-element-property :path link))
(file-path (if (string= "denote" link-type)
(denote-get-relative-path-by-id link-path)
link-path))
(file-name (file-name-nondirectory file-path))
(file-ext (file-name-extension file-name))
(denote-file-title (denote-retrieve-filename-title file-path)))
(pcase link-type
("denote"
(let* ()
(pcase (ky--publish/classify-denote-file file-name)
;; any page uses absolute URL e.g. `/page1'
('page (ky--publish/make-link (format "/%s" denote-file-title) desc))
;; any site resources go into shared resource directory e.g. `/media/<file>'
('media (ky--publish/make-link
(format "/%s/%s.%s"
ky-publish/media-destination-dirname
denote-file-title
file-ext)
desc))
('no-access (ky--publish/handle-no-access-file file-path info))
(_ (ky--publish/handle-unknown-file file-path info)))))
("file"
(if denote-file-title
;; If we match Denote extension (e.g. org) - assume it is a link to a post otherwise
;; treat as a shared resource - set href to the resource directory. Links have to be
;; relative, otherwise html backend will create a `file:' link. We don't want to handle
;; potentially inlined resources, let 'html deal with this.
;;
;; NOTE: perhaps mutating `link' is a bit dirty, but `org-element-copy' doesn't copy
;; properties set on a link e.g. `ATTR_HTML'
(if (string= file-ext (or denote-file-type "org"))
(ky--publish/make-link (format "/%s" denote-file-title) desc)
(let ((publish-path (format "../%s/%s.%s"
ky-publish/media-destination-dirname
denote-file-title
file-ext)))
(org-element-put-property link :path publish-path)
(org-export-data-with-backend link 'html info)))
;; a link to a regular file
(let ((publish-dir (ky-publish/destination-directory)))
(if (file-in-directory-p file-path publish-dir)
;; file is already in the publishing-dir, assume this is a dynamic resource generated
;; by a page with `ky-publish/dresource', form links relative to containing page:
;; <publish-dir>/<page>/<resource-path> -> ./<resource-path>
(let ((publish-path
(apply
'file-name-concat
(cdr (file-name-split (file-relative-name file-path publish-dir))))))
(org-element-put-property link :path publish-path)
(org-export-data-with-backend link 'html info))
;; otherwise bail out
(ky--publish/handle-unknown-file file-path info)))))
(_ (org-export-data-with-backend link 'html info)))))
Here we first check if we got a denote:
link, if so, we get some information about the file it points to and decide how to generate an HTML link based on the result from ky-publish/classify-denote-file
.
(defun ky--publish/classify-denote-file (file-name)
"Determine how FILE-NAME should be published.
Returns one of the following: 'page, 'media, 'no-access,
nil"
(when (denote-file-has-identifier-p file-name)
(let ((classify (lambda (regexp)
(ky--publish/denote-file-accesible-p file-name regexp))))
(cond
((funcall classify (ky-publish/denote-page-regexp)) 'page)
((funcall classify (ky-publish/denote-media-regexp)) 'media)
('no-access)))))
The function takes a couple of predefined rules to sort files into 4 categories:
page
- that's a post.media
- anything else that matches the regexp, a site resource.no-access
- any private resource that is not meant for publishing.nil
- not a Denote file.
And a couple of helper functions to check file access, generate HTML link elements and handle 'no-access
and unknown
links.
(defun ky-publish/filter-denote-files (regexp)
"Get a list of denote formatted filenames filtered by REGEXP."
(mapcar
'denote-get-file-name-relative-to-denote-directory
(denote-directory-files regexp)))
(defun ky--publish/denote-file-accesible-p (file-name regexp)
"Use REGEXP to determine if FILE-NAME can be published.
See `ky-publish-vars.el' for REGEXP definitions."
(member file-name (ky-publish/filter-denote-files regexp)))
;; TODO: handle IDs
(defun ky--publish/make-link (href desc)
"Create <a> element pointing to HREF with description DESC."
(format "<a href=\"%s\">%s</a>" href desc))
(defun ky--publish/handle-no-access-file (file-path info)
"Handle no-access links according to settings in INFO.
FILE-PATH is a relative path to a file returned by Denote."
(pcase (plist-get info :with-broken-links)
(`nil (user-error "Unable to resolve link for: %s, no access" file-path))
(`mark (format "<span class=\"no-access-link\">[NO ACCESS: %s]</span>" file-path))
(_ nil)))
(defun ky--publish/handle-unknown-file (file-path info)
"Handle links to unknown files according to settings in INFO.
FILE-PATH is a relative path to a file returned by Denote."
(pcase (plist-get info :with-broken-links)
(`nil (user-error "File does not match any type: %s" file-path))
(`mark (format "<span class=\"unknown-link\">[UNKNOWN FILE: %s]</span>" file-path))
(_ nil)))
Org Publish integration
If we take a look at ox-html
we can see a couple of functions that provide export and publish functionality:
org-html-export-as-html
exports current buffer to an HTML buffer.org-html-convert-region-to-html
converts selected region of an Org buffer into HTML in-place.org-html-export-to-html
export current buffer to an HTML file.org-html-publish-to-html
publishes a supplied Org file.
We are after org-html-publish-to-html
, that's the one we will use with Org Publish. org-html-export-to-html
is good for quick testing though, so we a bit of copy-pasting from ox-html
we can have both.
(defun ky-publish/export-denote-to-html (&optional async subtreep visible-only body-only ext-plist)
(interactive)
(let* ((extension (concat
(when (> (length org-html-extension) 0) ".")
(or (plist-get ext-plist :html-extension)
org-html-extension
"html")))
(file (ky--publish/setup-publish-filename #'org-export-output-file-name extension subtreep))
(org-export-coding-system org-html-coding-system))
(org-export-to-file 'ky-html file
async subtreep visible-only body-only ext-plist)))
(defun ky-publish/publish-denote-to-html (plist filename pub-dir)
(advice-add 'org-export-output-file-name :around #'ky--publish/setup-publish-filename)
(org-publish-org-to ky-publish/backend filename
(concat (when (> (length org-html-extension) 0) ".")
(or (plist-get plist :html-extension)
org-html-extension
"html"))
plist pub-dir)
(advice-remove 'org-export-output-file-name #'ky--publish/setup-publish-filename))
We can also try running ky-publish/export-denote-to-html
on any Org file, preferably with denote:
links and marvel at how everything is broken! Every single link points to a non-existing location. This is why we need an Org Publish project(s) to put files in the proper locations that we assume in ky--publish/html-link
.
Before we go to projects setup though, we still need to fix output structure for generated pages. Default org-export-output-file-name
function is not going to create <title>/index.html
structure for us. There are multiple ways to address the problem. For example output filename can be altered with EXPORT_FILE_NAME
property set in Org file. There are problems with this though. The property doesn't support macros. Perhaps we could include an elisp source block that would set a value for the property automatically10 when it detects our backend, but even then the function doesn't support creating new directories. To my knowledge there is no way to influence how Org Publish project writes output files. We can't rename them and apart from setting :publishing-directory
, there is no option for a more sophisticated output control.
It is not all lost though, to make this work we need just one small hack:
(defun ky--publish/setup-publish-filename (orig-fn &rest args)
"An override for `org-export-output-file-name'.
Instead of using full Denote-style filenames extract `title` part
and create <title>/index.html entries. <title> directory has to
be created here too as a side-effect, as Org Publish expects it
to exist. Since the resulting directory structure is flat,
<title> part of the filename is assumed to be unique.
ORIG-FUN/ARGS are the original function/args passed by
`advice-add'."
(let* ((orig-res (apply orig-fn args))
(html-ext (string-remove-prefix "." (nth 0 args)))
(pub-dir (nth 2 args))
(denote-title (denote-retrieve-filename-title orig-res)))
(if denote-title
(let ((output-dir (file-name-concat pub-dir denote-title)))
(make-directory output-dir t)
(file-name-concat output-dir (concat "index." html-ext)))
orig-res)))
Now we get the structure and naming we are after, things start to take shape! We are missing all the media resources though, and there is no root index.html
page, so at best you can get a file listing if your HTTP server is setup to do that. I am testing locally with darkhttpd
and file listing is exactly what I get.
Denote pages project
Let's start with a project to publish pages/posts:
(defun ky-publish/make-denote-pages-plist ()
`(
:base-directory ,(denote-directory)
:base-extension ,(or denote-file-type "org")
:publishing-directory ,(ky-publish/destination-directory)
:publishing-function ky-publish/publish-denote-to-html
:exclude ".*"
:include ,(ky-publish/filter-denote-files (ky-publish/denote-page-regexp))))
This isn't really a project but a minimal property list to make things work. It can be augmented with the rest of your site-specific settings with org-combine-plists
function. Lets quickly go over the settings:
:base-directory
is where our Denote files are. Checkout the source for all these helper functions and variables.:base-extension
is self-explanatory, that is the source file extension. We take it from Denote settings all or fallback toorg
if it is not defined. I have never tried writing Denote notes in anything but org files and it is a bit scary to test that…:publishing-directory
is where generated files will go.:include
and:exclude
are file filters. We exclude everything at first and then allow only certain files based on user settings. Remember that'no-access
classifier inky--publish/classify-denote-file
?
We can try running a minimal setup:
(defun get-site-projects ()
`(,(append '("denote-pages") (ky-publish/make-denote-pages-plist))))
(defun ky-publish/publish (projects force)
(setq org-publish-project-alist projects)
(org-publish-all force))
(ky-publish/publish (get-site-projects) t)
To give this a fighting chance we also need to tweak publishing settings:
(setq org-export-with-broken-links 'mark
org-html-html5-fancy t
org-html-doctype "html5")
The first setting makes sure we don't interrupt export if we encounter broken links, failing would be useful in production, but for testing we still want to get all the output, even if broken. The last 2 settings are optional, I just like the fact that Org will generate "fancy" HTML for me. For a real site you will need way more settings and tweaks, here we are after a minimal working solution.
Denote media project
To get media files in place we need a similar setup:
(defun ky-publish/make-denote-media-plist ()
"Minimal property setup for publishing non-page Denote files are resources."
`(
:base-directory ,(denote-directory)
:base-extension ,ky-publish/media-resource-regexp
:publishing-directory ,(ky-publish/media-destination-directory)
:publishing-function ky-publish/publish-denote-media
:exclude ".*"
:include ,(ky-publish/filter-denote-files (ky-publish/denote-media-regexp))))
Since we are dealing with Denote files, we can't use standard org-publish-attachment
function for :publishing-function
property because we need to strip everything but title
from filenames we publish.
(defun ky-publish/publish-denote-media (_plist filename pub-dir)
"Publish a denote file as a media resource.
FILENAME is the filename of the Org file to be published. PLIST
is the property list for the given project. PUB-DIR is the
publishing directory.
Return title.ext part of the filename."
(unless (file-directory-p pub-dir)
(make-directory pub-dir t))
(let* ((denote-title (denote-retrieve-filename-title filename))
(file-ext (file-name-extension filename))
(output (expand-file-name
(file-name-nondirectory (format "%s.%s" denote-title file-ext))
pub-dir)))
(unless (file-equal-p (expand-file-name (file-name-directory filename))
(file-name-as-directory (expand-file-name pub-dir)))
(copy-file filename output t))
output))
Updated setup:
(defun get-site-projects ()
`(,(append '("denote-pages") (ky-publish/make-denote-pages-plist))
,(append '("denote-media") (ky-publish/make-denote-media-plist))))
(ky-publish/publish (get-site-projects) t)
Web-specific data project
We don't need anything else for our minimal setup but for a real site you are likely going to have some design resources e.g. CSS files. Those can be published with a project of their own:
(defun ky-publish/make-styles-plist (rel-src-dir)
"Minimal property setup for publishing style files, CSS and so on."
`(
:base-directory ,(file-name-concat (denote-directory) rel-src-dir)
:base-extension "css"
:publishing-directory ,(ky-publish/styles-destination-directory)
:publishing-function org-publish-attachment
:recursive t))
(defun ky-publish/make-media-plist (rel-src-dir)
"Minimal property setup for publishing media files."
`(
:base-directory ,(file-name-concat (denote-directory) rel-src-dir)
:base-extension ,ky-publish/media-resource-regexp
:publishing-directory ,(ky-publish/media-destination-directory)
:publishing-function org-publish-attachment
:recursive t))
(defun ky-publish/make-web-settings-plist (rel-src-dir)
"Minimal property setup for publishing any other required files, robots.txt etc"
`(
:base-directory ,(file-name-concat (denote-directory) rel-src-dir)
:base-extension any
:publishing-directory ,(ky-publish/destination-directory)
:publishing-function org-publish-attachment
:recursive t))
Org Publishing also supports sub-projects via :components
property.
Dynamic resources
These days I am busy learning Julia, even though what I should really be doing is looking for a job… ð Anyway, I use Jupyter to run Julia code blocks, it works pretty well and I can also pick different kernels and just as easily run SageMath11. None of my mathematical/scientific endeavours are worth sharing, but I though it would be cool to make sure I am prepared! ð
Luckily it is pretty easy to publish generated files. The function below alters the destination directory if it detects that it runs under Org Export. Generated files are put under their parent page directory. There is a condition in ky--publish/html-link
to handle them and rewrite links accordingly. If there is no export running this function is mostly transparent.
I also tried this approach with Gnuplot and it works just as well, but overall I have no idea if it supports every single source block out there.
(defun ky-publish/dresource (file-name &optional fallback)
"Can be used to generate :file param for Babel SRC blocks.
If export backend matches one we defined and the current buffer is
a denote page, generate filename as `<page_name>/FILE-NAME' in
the `:publishing-directory', `ky--publish/html-link' will set
links accordingly. In all other cases fallback to default SRC
block behaviour with :file set to FALLBACK."
(let ((backend (when (boundp 'org-export-current-backend) org-export-current-backend)))
(if (eq ky-publish/backend backend)
(when-let* ((buffer-name (buffer-file-name))
(parent-dir (denote-retrieve-filename-title buffer-name))
(out-dir (file-name-concat (ky-publish/destination-directory) parent-dir))
(file (file-name-concat out-dir file-name)))
(make-directory (file-name-parent-directory file) t)
file)
fallback)))
If you are wondering why can't I generate files before publishing and then publish as any other resource - I guess, it is my personal preference to not litter in my notes with generated files. For the same reason e.g. Jupyter puts files under .ob-jupyter
by default.
Sitemap
What's left is a sitemap or simply an index page. This part is even more shabby than what you have seen so far, but nevertheless with this last task out of our way it is going to be a functional site.
According to the documentation all we need to do is set a couple of properties on denote-pages
project to get an auto-generated index page. For anything but testing purposes you will need more control over the process. Luckily Org provides hooks for that, namely :sitemap-function
and :sitemap-format-entry
. Good news are - we are not going to use them! We would need to generate custom HTML inside these functions but our project is not about assuming particular design choices.
With that in mind, let's generate the simplest sitemap we can have.
(defun ky-publish/make-denote-pages-plist ()
`(
:base-directory ,(denote-directory)
:base-extension ,(or denote-file-type "org")
:publishing-directory ,(ky-publish/destination-directory)
:publishing-function ky-publish/publish-denote-to-html
:exclude ".*"
:include ,(ky-publish/filter-denote-files (ky-publish/denote-page-regexp))
:sitemap-filename "index.org"
:sitemap-style list
:auto-sitemap t))
Now another run of (ky-publish/publish (get-site-projects) t)
completes the assignment. ðĪŠ
Results
Results are - it does what I want it to do. At the moment of writing it is a very fresh code. I am sure it will not take long for things to start breaking. Also, as stated in the goals, this is a minimal setup to make it possible to publish Denote files with denote:
links. The ox-html
generated result is still far from anything pretty (at least for me).
On my site I use Matcha CSS for styling. Matcha is a semantic CSS library, I like the simplicity of the approach both in terms of CSS and the clarity of HTML structure. It does take some extra work with ox-html
output though. My site project has more custom transcoders to rewrite some parts into semantic equivalents and it is an ongoing process.
Project code
The repository is on github - ky-publish.
As of now I haven't published my website code anywhere yet as it is very WIP.
Footnotes:
- 1
Publishing - Org includes a publishing management system that allows you to configure automatic HTML conversion of projects composed of interlinked Org files.
- 2
Jupyter is the latest web-based interactive development environment for notebooks, code, and data. Its flexible interface allows users to configure and arrange workflows in data science, scientific computing, computational journalism, and machine learning.
- 3
Julia is a programming language, designed to be fast and productive for e.g. data science, artificial intelligence, machine learning, modeling and simulation, most commonly used for numerical analysis and computational science.
- 4
Org Sitemap properties documentation.
- 7
It is a secret Blondihacks reference.
- 8
ox-html
is the default HTML export backend. See Exporting and HTML Export.
- 9
Element
andObject
have a specific meaning in the Org context, see Org Syntax terminology and conventions.
- 10
Literate Documentation with Emacs and Org Mode very informative presentation on what can be done in Org documents. I have learnt a lot of tricks from it.