Hugo Blogging in Emacs


When I started to use Hugo to write this blog last year, I noticed that there is an easy-hugo package of Emacs that there are many people use it. So I installed it that time, but I didn't use many of its features along the way. The only command I used was easy-hugo-current-time. I used it to update the Hugo timestamps manually as in the format of 2022-10-15T09:45:35+08:00.

My most desirable feature is to use it to select tags easily when I start to write a new post, but I never got it to work.

/img/2022-10-10-emacs-hugo-select-tags.png
Select tags using ivy in Emacs

In the past week, I found that it may be time to improve the workflow. So here are my requirements:

  • Create a draft post with basic metadata quickly
  • Collect the tags from the past posts in the current directory, so then pop up a menu to let me select from them
  • Update the lastmod timestamp when I changed the content

Obtain the Hugo timestamp

Before all, let's take a look at the timestamps in Hugo. It has a format of 2022-10-15T09:06:04+08:00. Emacs doesn't have a command to insert this format, so I need to make such a helper function (I copied it from easy-hugo and slightly tweaked it.):

(defun w/hugo-current-time ()
  "Get the current timestamp for hugo."
  (let ((tz (format-time-string "%z")))
    (insert (format-time-string "%Y-%m-%dT%T")
            (substring tz 0 3) ":" (substring tz 3 5))))

Create a draft post quickly

Obviously, yasnippet is suitable for this case. I can create a snippet for org-mode to complete this task:

# -*- mode: snippet -*-
# name: new hugo post
# key: hugonew
# --
,#+title: $1
,#+date: `(w/hugo-current-time)`
,#+lastmod: `(w/hugo-current-time)`
,#+tags[]: $0
,#+draft: true

When I come up with an idea, I can open a new file (C-x C-f) and type hugonew<TAB> to start writing immediately.

Collect and select tags

After writing a few posts, we can't remember what tags we already have. Previously I just used counsel-rg to find them and then typed them in manually. It was tedious and error-prone that I may have slightly different tags like foo and Foo.

M-x easy-hugo-complete-tags looks good to solve my problem, but it didn't work in my setup. After peeking at its implementation, I saw that it broke for the following reasons:

  1. It assumes that users have the blog posts in the directory of /.../<hugo root>/content/. I don't qualify as I have a multilingual directory hierarchy.
  2. It searches for tags by regex "^[T\\|t]ags[:]? [=]?+.*\\[\\(.+?\\)\\]$", my tag lines (e.g. #+tags[]: Emacs Hugo) did not match.
  3. It uses popup-menu to bring up a widget. We need to click on the mouse to select suitable candidates. I don't like to interact with it with a mouse.

My use case for this is collecting the tags from the current directory and then feeding the tags to the built-in completion API, completing-read.

The first version

Stand on the shoulders of easy-hugo, it didn't take me too much time to write the first version:

(defun w/hugo-select-tags ()
  "Select tags from the hugo org files in the current dir.

Note that it only extracts tags from lines like the below:
,#+tags[]: Emacs Org-mode"
  (interactive)
  (let ((files (directory-files-recursively default-directory "\\.org$")))
    (let ((source (with-temp-buffer
		    (while files
                      (when (file-exists-p (car files))
                        (insert-file-contents (car files)))
		      (pop files))
		    (buffer-string))))
      (save-match-data
	(let ((pos 0)
	      matches)
	  (while (string-match "^#\\+[Tt]ags\\[\\]: \\(.+?\\)$" source pos)
	    (push (match-string 1 source) matches)
	    (setq pos (match-end 0)))
	  (insert
	   (completing-read
            "Insert a tag: "
            (sort
	     (delete-dups
	      (delete "" (split-string
			  (replace-regexp-in-string "[\"\']" " "
						    (replace-regexp-in-string
						     "[,()]" ""
						     (format "%s" matches)))
			  " ")))
             (lambda (a b)
               (string< (downcase a) (downcase b)))))))))))

The improved version: select multiple tags at once

The first version works in the sense of functionality. But when I used it to write this post, I found an inconvenience: I had to M-x w/hugo-select-tags several times if I wanted to select a few tags. That's so boring!

But completing-read doesn't support selecting multiple candidates. Fortunately, ivy supports it. Following this issue regarding to mark candidates, I came up with this improved version:

(require 'ivy)
(require 'ivy-hydra)

(defun w/hugo--collect-tags ()
  "Collect hugo tags from the org files in the current dir.

Note that it only extracts tags from lines like the below:
,#+tags[]: Emacs Org-mode"
  (interactive)
  (let ((files (directory-files-recursively default-directory "\\.org$")))
    (let ((source (with-temp-buffer
		    (while files
                      (when (file-exists-p (car files))
                        (insert-file-contents (car files)))
		      (pop files))
		    (buffer-string))))
      (save-match-data
	(let ((pos 0)
	      matches)
	  (while (string-match "^#\\+[Tt]ags\\[\\]: \\(.+?\\)$" source pos)
	    (push (match-string 1 source) matches)
	    (setq pos (match-end 0)))
          (sort
	   (delete-dups
	    (delete "" (split-string
			(replace-regexp-in-string "[\"\']" " "
						  (replace-regexp-in-string
						   "[,()]" ""
						   (format "%s" matches)))
			" ")))
           (lambda (a b)
             (string< (downcase a) (downcase b)))))))))

(defun w/hugo-select-tags ()
  "Select tags for the current hugo post."
  (interactive)
  (ivy-read "Insert tags: "
            (w/hugo--collect-tags)
            :action
            (lambda (tag)
              (insert (if (char-equal (preceding-char) 32)
                          ""
                        " ")
                      tag))))

When I want to add more tags to the current post, I M-x w/hugo-select-tags, and it will bring up the ivy completion interface. If I only want to select one tag, I can type some characters to filter it out and then hit ENTER.

Or, If I want to select multiple candidates, it's a bit more complex, and here are the steps:

  1. M-x w/hugo-select-tags as before
  2. Type C-o to bring up the ivy-hydra menu, use M-n=/=M-p or h j k l to navigate the items
  3. Select the tags by typing m (it means mark)
  4. When you're finish, hit d (done) to insert the selections as tags

P.S. After I finished the command, I found that Emacs had a built-in facility called completing-read-multiple (see this StackOverflow post), but it's not as user-friendly as ivy, so I'd rather stick to ivy.

Update the lastmod timestamp automatically

The last feature I want is that Emacs would automatically update the lastmode timestamp when I modify some posts, so that I can save some typing and have a bit more time to drink a cup of tea 🫖.

(defun w/hugo-update-lastmod ()
  "Update the `lastmod' value for a hugo org-mode buffer."
  (interactive)
  (ignore-errors
    (when (eq major-mode 'org-mode)
      (save-excursion
        (goto-char (point-min))
        (search-forward-regexp "^#\\+lastmod: ")
        (delete-region (point) (line-end-position))
        (insert (w/hugo-current-time))))))

(add-hook 'before-save-hook #'w/hugo-update-lastmod)

Note that I utilize the before-save-hook hook here, so I should be careful not to freeze Emacs here. That's why I have a predicate to let major modes other than org-mode exit ASAP.

Let me know if you have a better way to update the timestamp automatically.

Sum up

The final version is the following:

(defun w/hugo--collect-tags ()
  "Collect hugo tags from the org files in the current dir.

Note that it only extracts tags from lines like the below:
#+tags[]: Emacs Org-mode"
  (interactive)
  (let ((files (directory-files-recursively default-directory "\\.org$")))
    (let ((source (with-temp-buffer
		    (while files
                      (when (file-exists-p (car files))
                        (insert-file-contents (car files)))
		      (pop files))
		    (buffer-string))))
      (save-match-data
	(let ((pos 0)
	      matches)
	  (while (string-match "^#\\+[Tt]ags\\[\\]: \\(.+?\\)$" source pos)
	    (push (match-string 1 source) matches)
	    (setq pos (match-end 0)))
          (sort
	   (delete-dups
	    (delete "" (split-string
			(replace-regexp-in-string "[\"\']" " "
						  (replace-regexp-in-string
						   "[,()]" ""
						   (format "%s" matches)))
			" ")))
           (lambda (a b)
             (string< (downcase a) (downcase b)))))))))

(defun w/hugo-select-tags ()
  "Select tags for the current hugo post."
  (interactive)
  (ivy-read "Insert tags: "
            (w/hugo--collect-tags)
            :action
            (lambda (tag)
              (insert (if (char-equal (preceding-char) 32)
                          ""
                        " ")
                      tag))))

(defun w/hugo-update-lastmod ()
  "Update the `lastmod' value for a hugo org-mode buffer."
  (interactive)
  (ignore-errors
    (when (eq major-mode 'org-mode)
      (save-excursion
        (goto-char (point-min))
        (search-forward-regexp "^#\\+lastmod: ")
        (delete-region (point) (line-end-position))
        (insert (w/hugo-current-time))))))

(defun w/hugo-current-time ()
  "Get timestamp for hugo."
  (let ((tz (format-time-string "%z")))
    (insert (format-time-string "%Y-%m-%dT%T")
            (substring tz 0 3) ":" (substring tz 3 5))))

(add-hook 'before-save-hook #'w/hugo-update-lastmod)

See also

comments powered by Disqus