Blogging using Denote and Hugo

Table of Contents

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.

Nix #

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
          ];
        };
      }
    );
}

Theme #

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

Since I use hugo-modus for my blog and also work on its development, I need to switch between the local and remote versions. Fortunately, Hugo supports split configuration by environment:

config/
├── _default
│   └── hugo.toml
└── development
    └── hugo.toml

In config/_default/hugo.toml, set the theme to be used in the production environment:

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

In config/development/hugo.toml, override the theme to use the local hugo-modus directory:

[module]
  replacements = "github.com/goofansu/hugo-modus -> ../../hugo-modus"

Check the active modules by running hugo mod graph:

> hugo mod graph -e development
project ../../hugo-modus

> hugo mod graph -e production
github.com/goofansu/yejun.dev github.com/goofansu/hugo-modus@v0.0.0-20250423135050-b8cf9a1e9268

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 to 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"↩︎