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.
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:
- 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. - It searches for tags by regex
"^[T\\|t]ags[:]? [=]?+.*\\[\\(.+?\\)\\]$"
, my tag lines (e.g.#+tags[]: Emacs Hugo
) did not match. - 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:
M-x w/hugo-select-tags
as before- Type
C-o
to bring up the ivy-hydra menu, useM-n=/=M-p
orh j k l
to navigate the items - Select the tags by typing
m
(it meansmark
) - 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.
Summing 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)