Blogging using Denote and Hugo

I’ve been thinking of writing a post about my current blogging workflow for quite some time. After reading Blogging using Emacs Org Roam and Hugo, I noticed we have a similar approach: we both use Nix, Hugo, and ox-hugo. The main difference is that my workflow is based on Denote instead of Org Roam.

Denote #

Denote is a simple note-taking tool created by Protesilaos Stavrou (known as Prot), based on the idea that notes should follow a predictable and descriptive file-naming scheme. The default format is DATE==SIGNATURE--TITLE__KEYWORDS.EXTENSION, such as 20231007T104700--static-website-with-hugo-and-nix__hugo_nix.org. This format is both URL-friendly and easy to search.

Attachments #

My note-taking system is inspired by Taking Notes With the Emacs Denote Package. I’m impressed by the “Attachments” section:

An attachment in Denote is any file that is not recognised by Denote as a note, but with a compatible filename. Any file stored in the Denote directory that follows the Denote file naming convention will be recognised as an attachment and can be linked from inside a Denote file.

I save all attachments in the attachments directory alongside my notes, and I insert links using [[file:]] syntax rather than denote-link. The benefits are:

  • Since most attachments are pictures, I can view them directly in an Org file using org-toggle-inline-images. They’re also visible on GitHub.
  • Attachments in the form of [[file:]] are automatically copied to the <HUGO_WEBSITE>/static/attachments/1 directory, with no manual work needed.

I use a function to insert attachments from any location on my computer to the current note by inserting a link if the attachment is already in the attachments directory, or renaming and moving the attachment to the attachments directory first, and then inserting a link.

(setq my-notes-attachments-directory (expand-file-name "attachments/" (denote-directory)))

(defun my/denote-org-extras-insert-attachment (file)
    "Process FILE to use as an attachment in the current buffer.

If FILE is already in the attachments directory, simply insert a link to it.
Otherwise, rename it using `denote-rename-file' with a title derived from
the filename, move it to the attachments directory, and insert a link.

The link format used is '[[file:attachments/filename]]', following Org syntax.
This function is ideal for managing referenced files in note-taking workflows."
    (interactive (list (read-file-name "File: " my-notes-attachments-directory)))
    (let* ((orig-buffer (current-buffer))
           (attachments-dir my-notes-attachments-directory))

      ;; Check if the file is already in the attachments directory
      (if (string-prefix-p
           (file-name-as-directory attachments-dir)
           (expand-file-name file))

          ;; If already in attachments, just insert the link
          (with-current-buffer orig-buffer
            (insert (format "[[file:attachments/%s]]" (file-name-nondirectory file))))

        ;; Otherwise, rename and move the file
        (let ((title (denote-sluggify-title (file-name-base file))))
          (when-let* ((renamed-file (denote-rename-file file title))
                      (renamed-name (file-name-nondirectory renamed-file))
                      (final-path (expand-file-name renamed-name attachments-dir)))
            (rename-file renamed-file final-path t)
            (with-current-buffer orig-buffer
              (insert (format "[[file:attachments/%s]]" renamed-name))))))))

Here’s a screencast of inserting an image from the Desktop directory into the current note using this function:

I create references to my notes using denote-link. The problem is that after exporting, these references are converted into Markdown links that point to the original Org files, which are inaccessible on the published website:

[Static website with Hugo and Nix](20231007T104700--static-website-with-hugo-and-nix__hugo_nix.org)

I add an denote-link-ol-export advice to solve the problem:

(advice-add 'denote-link-ol-export :around
            (lambda (orig-fun link description format)
              (if (and (eq format 'md)
                       (eq org-export-current-backend 'hugo))
                  (let* ((path (denote-get-path-by-id link))
                         (export-file-name
                          (or
                           ;; Use export_file_name if it exists
                           (when (file-exists-p path)
                             (with-temp-buffer
                               (insert-file-contents path)
                               (goto-char (point-min))
                               (when (re-search-forward "^#\\+export_file_name: \\(.+\\)" nil t)
                                 (match-string 1))))
                           ;; Otherwise, use the original file's base name
                           (file-name-nondirectory path))))
                    (format "[%s]({{< relref \"%s\" >}})"
                            description
                            export-file-name))
                (funcall orig-fun link description format))))

Now the references use the relref shortcode, which is a Hugo feature to create relative links to documents:

[Static website with Hugo and Nix]({{< relref "static-website-with-hugo-and-nix" >}})

You can visit the article here: Static website with Hugo and Nix.

Hugo #

Hugo is a fast static site generator that’s easy to install with just an executable binary. However, I use Nix for my development environment because I need go for Hugo Modules:

{
  description = "My personal website";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs =
    {
      self,
      nixpkgs,
      flake-utils,
    }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShells.default = pkgs.mkShell {
          packages = with pkgs; [
            hugo
            go
          ];
        };
      }
    );
}

In hugo.toml, I import the theme using:

[module]
  [[module.imports]]
    path = "github.com/goofansu/hugo-modus"

The hugo-modus theme was created by myself specifically for this blog. It uses the colour palette of the Modus themes, and supports both light and dark modes.

Switching local and remote hugo-modus #

Since I use hugo-modus for my blog and also work on its development, I often switch between the local and remote versions. I created a Makefile for efficient switching:

.PHONY: dev prod local remote

dev: local
	hugo server --disableFastRender --navigateToChanged --buildDrafts

prod: remote
	hugo mod get -u
	hugo mod tidy

local:
	@if ! grep -q "^replace" go.mod; then \
		sed -i 's/^\/\/ replace/replace/' go.mod; \
		echo "Switched to local modules"; \
		hugo mod tidy; \
	fi

remote:
	@if grep -q "^replace" go.mod; then \
		sed -i 's/^replace/\/\/ replace/' go.mod; \
		echo "Switched to remote modules"; \
		hugo mod tidy; \
	fi

Using make dev will apply the local hugo-modus, while make prod will update to the latest remote hugo-modus. The secret lies in go.mod. By default, it uses the local version, but when commented, it switches to the remote version:

replace github.com/goofansu/hugo-modus => ../hugo-modus

Denote + Hugo #

I use ox-hugo to export Org-mode files to Markdown for Hugo. It offers two options for organizing posts: “One post per Org subtree” and “One post per Org file”. In my blogging workflow, I choose the “One post per Org file” method because notes are just posts.

Using this post as an example, after creating the Org-mode note using the denote command, it contains the following content.

#+title:      Blogging using Denote and Hugo
#+date:       [2025-03-04 Tue 17:17]
#+filetags:   :blogging:denote:hugo:
#+identifier: 20250304T171750

The note is not Hugo-exportable at the moment.

Make note Hugo-exportable #

Set #+hugo_base_dir, and that’s the only necessary configuration:

#+hugo_base_dir: ~/src/yejun.dev

By default, M-x org-hugo-export-to-md exports the note to ~/src/yejun.dev/content/posts/20250304T171750--blogging-using-denote-and-hugo__blogging_denote_hugo.md.

Thanks to Denote’s file-naming scheme, which creates URL-friendly names, I can export my TILs and Links using their original file names.

But I prefer a shorter file name #

Set #+export_file_name to define the name of the exported file:

#+export_file_name: blogging-using-denote-and-hugo

This time, M-x org-hugo-export-to-md exports the note to ~/src/yejun.dev/content/posts/blogging-using-denote-and-hugo.md.

How does the Markdown file look? #

Looking at the Markdown file, it contains a YAML front-matter2:

---
title: "Blogging using Denote and Hugo"
author: ["Yejun Su"]
date: 2025-03-04T17:17:00+08:00
tags: ["blogging", "denote", "hugo"]
---

Where does the front-matter come from? #

ox-hugo converts #+title, #+date, and #+filetags into Hugo front-matter and automatically includes the author which reads user-full-name. Org meta-data to Hugo front-matter lists all Hugo front-matter translations for file-based exports.

Interestingly, #filetags isn’t included in that list; instead, there is #+hugo_tags. I found #+filetags wasn’t supported until this pull request, which was made to support Org Roam’s parsing of tags from the #+filetags keyword starting with Org Roam v2.

I want to export a TIL note #

By default, ox-hugo exports notes as posts, you can change the behaviour by:

  • Setting #+hugo_section in the note

    #+hugo_section: til
    
  • Setting the org-hugo-default-section-directory variable globally

    (setq org-hugo-default-section-directory "til")
    

See Transform ox-hugo anchors into links for an example.

Can I automatically export a note every time I save it? #

Sure. ox-hugo offers a guide on automatically exporting when saving. Just add the following snippet at the end of the note:

* Footnotes
* COMMENT Local Variables :ARCHIVE:
# Local Variables:
# eval: (org-hugo-auto-export-mode)
# End:

Can I search all Hugo-exportable notes? #

Yes. The approach is simple - I just search ripgrep the Org files in my denote-directory for #+hugo_base_dir:

(defun my/org-hugo-denote-files ()
    "Return a list of Hugo-compatible files in `denote-directory'."
    (let ((default-directory (denote-directory)))
      (process-lines "rg" "-l" "^#\\+hugo_base_dir" "--glob" "*.org")))

(defun my/org-hugo-denote-files-find-file ()
    "Search Hugo-compatible files in `denote-directory' and visit the result."
    (interactive)
    (let* ((default-directory (denote-directory))
           (prompt (format "Select FILE in %s: "  default-directory))
           (selected-file (consult--read
                           (my/org-hugo-denote-files)
                           :state (consult--file-preview)
                           :history 'denote-file-history
                           :require-match t
                           :prompt prompt)))
      (find-file selected-file)))

Can I export all Hugo-exportable notes? #

Absolutely yes! Just loop my/org-hugo-denote-files and execute org-hugo-export-to-md:

(defun my/org-hugo-export-all-denote-files ()
    "Export all Hugo-compatible files in `denote-directory'."
    (interactive)
    (let ((org-export-use-babel nil))
      (dolist (file (my/org-hugo-denote-files))
        (with-current-buffer (find-file-noselect file)
          (org-hugo-export-to-md)))))

Conclusion #

  • I can publish any note to any Hugo website by setting #+hugo_base_dir.
  • I can publish any note to any Hugo section by setting #+hugo_base_section.
  • I can insert any file from my computer into any note using denote-rename-file.

Together, these advantages create an efficient publishing workflow.

PS: Emacs code used in this post can be found here.


  1. ox-hugo by default copies files to static/ox-hugo directory, I changed the behaviour by customizing org-hugo-default-static-subdirectory-for-externals to "attachments"↩︎

  2. ox-hugo supports exporting the front-matter in TOML (default) or YAML. I prefer YAML and have customized org-hugo-front-matter-format to use "yaml"↩︎