Blogging with Denote and Hugo

A comfy blogging setup

📅 26 Mar 2024 | ~5 min read
Tags: #blogging #denote #emacs

A few months ago, I replaced Org-Roam with Prot’s Denote package and have been using it for a while now. I have mostly been using it to manage my collection of personal notes, but I also want the option to publish any note as a blog post quickly and easily.

Since starting to use Denote, I am trying to think of notes and public blog posts not as two separate entities, but as a part of a larger knowledge base. Unless I’m writing something specific like notes on a book, which would go in the /books subdirectory, most notes start their life in the root of my denote-directory before being moved to a relevant subdirectory. Blog posts are moved to the /blog subdirectory.

With ox-hugo, you can write all posts in a single Org file, or like I do, you can have a single file for each post. ox-hugo also provides a really useful feature for auto-exporting on save. I enabled it by adding the following in the .dir-locals.el file in the /blog subdirectory.

((org-mode . ((eval . (org-hugo-auto-export-mode))
              (org-hugo-section . "blog"))))

The three stages of a blog post

I make use of Denote’s file-naming scheme to easily distinguish between each of the following states that a note can be in:

├── 20230708T141810--books__metanote.org
├── ........
├── blog
│   ├── ........
│   ├── 20231104T134226--a-dwim-fullscreen-function-for-emacs-and-sway__emacs_linux.org
│   └── 20240323T143034--hacking-on-denote-and-hugo__blogging_denote_draft_emacs.org

Linking

Prot introduced a new set of link parameters specifically for Denote. The file’s Denote identifier is used to refer to the file you want to link to [[denote:20230708T141810][Books]] and the title of the file is the description. I have seen some Emacs users complain that these links are not portable, but for me they are very useful as the links continue to work even when I move files to different subdirectories.

I modified the included denote-link-ol-export so that links to private notes or drafts are not exported, but links to public notes are exported as Hugo relative links. There is only one change from the original function, in which I use my/denote-markdown-export to determine which type of note the link refers to and format it accordingly.

(defun my/denote-link-ol-export (link description format)
  " Modified version of `denote-link-ol-export'.
Replace markdown export with `my/denote-markdown-export'

Original docstring below:
Export a `denote:' link from Org files.
The LINK, DESCRIPTION, and FORMAT are handled by the export
backend."
  (let* ((path-id (denote-link--ol-resolve-link-to-target link :path-id))
         (path (file-relative-name (car path-id)))
         (p (file-name-sans-extension path))
         (id (cdr path-id))
         (desc (or description (concat "denote:" id))))
    (cond
     ((eq format 'html) (format "<a href=\"%s.html\">%s</a>" p desc))
     ((eq format 'latex) (format "\\href{%s}{%s}" (replace-regexp-in-string "[\\{}$%&_#~^]" "\\\\\\&" path) desc))
     ((eq format 'texinfo) (format "@uref{%s,%s}" path desc))
     ((eq format 'ascii) (format "[%s] <denote:%s>" desc path)) ; NOTE 2022-06-16: May be tweaked further
     ((eq format 'md) (my/denote-markdown-export link desc))
     (t path))))

(defun my/denote-markdown-export (link desc)
  "Format the way Denote links are exported to markdown.
  If LINK is considered private or a draft, return DESC.
  If LINK is considered a public note, format it as a Hugo relative link. "
  (let ((path (denote-get-path-by-id link)))
    (if (not (string-match "/blog" path))
        (format "%s" desc)
      (if (string-match "_draft" path)
          (format "%s" desc)
        (format "[%s]({{< relref \"%s\" >}})"
                desc
                (denote-sluggify-title
                 (denote-retrieve-filename-title path)))))))

(org-link-set-parameters "denote" :export #'my/denote-link-ol-export)

With that, I am able to covert the following Org-Mode links:

This is a private note: [[denote:20230708T141810][Books]]
This is a draft blog post: [[denote:20240323T143034][Hacking on Denote and Hugo]]
This is a published blog post: [[denote:20231104T134226][A DWIM fullscreen function for Emacs and Sway]]

Into this Markdown in which only public notes are exported:

This is a private note: Books
This is a draft blog post: Hacking on Denote and Hugo
This is a published blog post: [A DWIM fullscreen function for Emacs and Sway]({{< relref "a-dwim-fullscreen-function-for-emacs-and-sway" >}})

Moving between the three stages

The following code requires Denote version 2.3.0 as my/insert-hugo-export-file-name makes use of the denote-sluggify-title, if you are using an earlier version of Denote, you can replace that function with denote-sluggify.

Changing a note from a draft is quite simple. my/denote-convert-note-to-blog-post is a wrapper for simple functions that do the following:

  1. Move file to /blog subdirectory
  2. Add Denote keyword draft
  3. Add #+hugo_draft: t to file metadata
  4. Sluggify Denote title and add #+export_file_name to file metadata
(defun my/insert-hugo-draft-status ()
  "Add metadata to current org-mode file marking it as a Hugo draft."
  (save-excursion
    (goto-char 0)
    (search-forward "filetags")
    (end-of-line)
    (insert "\n#+hugo_draft: t ")))

(defun my/insert-hugo-export-file-name ()
  "Add metadata to current org-mode file containing export file name.
  Export File Name is returned by `denote-retrieve-title-value'."
  (save-excursion
    (goto-char 0)
    (search-forward "filetags")
    (end-of-line)
    (insert (format
             "\n#+export_file_name: %s.md"
             (denote-sluggify-title
              (denote-retrieve-title-value buffer-file-name 'org))))))

(defun my/move-current-buffer-file-to-subdirectory (subdirectory)
  "Move file of current buffer to SUBDIRECTORY seamlessy.
  There should be no discernable difference in the buffer's appearance."
  (let ((input-file (buffer-file-name))
        (output-file (concat (denote-directory)
                             subdirectory
                             (file-relative-name (buffer-file-name))))
        (window-pos (window-start))
        (cursor-pos (point)))
    (save-buffer)
    (rename-file input-file output-file)
    (find-file output-file)
    (set-window-start (selected-window) window-pos)
    (goto-char cursor-pos)
    (kill-buffer (get-file-buffer input-file))))

(defun my/denote-convert-note-to-blog-post ()
  "Mark file of current Denote buffer to be marked as a draft blog post."
  (interactive)
  (my/move-current-buffer-file-to-subdirectory "blog/")
  (denote-keywords-add '("draft"))
  (my/insert-hugo-draft-status)
  (my/insert-hugo-export-file-name))

To change a note from a draft to a public note, my/denote-publish-hugo-post is another wrapper around simple functions that do the following:

  1. Format current date and add #+hugo_publishdate to file metadata
  2. Remove Org-Mode draft tag
  3. Remove #+hugo_draft: t from file metadata
  4. Rename file and remove draft from Denote keywords
  (defun my/insert-hugo-published-date ()
    "Format the current date and add it to Org-Mode metadata."
    (save-excursion
      (goto-char 0)
      (search-forward "filetags")
      (end-of-line)
      (insert (concat "\n#+hugo_publishdate: "(format-time-string  "%Y-%m-%d")))))

(defun my/denote-remove-draft-tag-from-metadata ()
  "Remove \"draft\" tag from Org-Mode metadata."
  (save-excursion
    (goto-char 0)
    (search-forward "filetags")
    (when (search-forward-regexp ":draft\\|draft:" (line-end-position) t)
      (replace-match "")
      (when (and (looking-at ":$\\|: ") (looking-back " "))
        (delete-char 1)))))

(defun my/denote-remove-hugo-draft-status ()
  "Remove Hugo draft entry from Org-Mode metadata."
  (save-excursion
    (goto-char 0)
    (when (search-forward "#+hugo_draft: t")
      (beginning-of-line)
      (kill-line)
      (kill-line))))

(defun my/denote-remove-keyword-from-filename (keyword)
  "Remove Denote keyword \"draft\" from filename of current file."
  (denote-rename-file buffer-file-name
                      (denote-retrieve-filename-title buffer-file-name)
                      (delete keyword
                              (denote-retrieve-keywords-value
                               buffer-file-name 'org))
                      nil))

(defun my/denote-publish-hugo-post ()
  "Mark file of current `denote' buffer to be published as a blog post."
  (interactive)
  (my/insert-hugo-published-date)
  (my/denote-remove-hugo-draft-status)
  (my/denote-remove-draft-tag-from-metadata)
  (my/denote-remove-keyword-from-filename "draft"))

Conclusion

Are these tweaks going to make me a prolific blogger? Probably not, but they gave me a good opportunity to craft a comfy setup while also practising some Emacs Lisp.

✉️ Respond by Email.