Hugo posting in Emacs
I dug a bunch of stuff out of archived configs to get the Hugo blog going again, including my old ox-hugo setup.
ox-hugo
lets you keep a monolithic org file where each post is an org heading:
1** SyncTrain for Syncthing on iOS :syncthing:ios:iphone:
2:PROPERTIES:
3:EXPORT_FILE_NAME: 2025-04-20-synctrain-for-syncthing-on-ios
4:EXPORT_HUGO_DATE: <2025-04-20>
5:EXPORT_DATE: 2025-04-20
6:EXPORT_HUGO_SECTION: blog
7:EXPORT_HUGO_CUSTOM_FRONT_MATTER+: :cover '((image ."" ) (caption . "" ))
8:EXPORT_HUGO_CUSTOM_FRONT_MATTER+: :images '(/mph-logo.png)
9:EXPORT_DESCRIPTION:
10:END:
11
12A few years ago I gave Mobius Sync a try as a Syncthing client on my iPhone and iPad. That went about as well as you'd expect for an iOS adaptation of something that wants to be an always-on filesystem-watching daemon. It wasn't really worth the stress of wondering what quantum state of sync everything is in, and I hated having to explicitly open it up to nudge it to sync.
If a heading is marked as TODO
, that translates to “draft” for Hugo. If you use org tags in the heading :tag1:tag2:
those become post tags.
If you set up org-capture
and a few hooks correctly, it takes a lot of friction away by exporting the Markdown files when you save the file.
Something has changed since I was last using it regularly, and some bugs crept into my setup. I was willing to live with a few of them, but last night I came across some goofy thing where Hugo’s Markdown renderer (goldmark) and ox-hugo were interacting strangely, and the org-to-markdown conversion was indenting unordered lists enough that goldmark picked them up as indented code blocks. I did some poking around and saw that a lot of people have been vexed by that: goldmark uses the CommonMark specification, which includes indented code blocks, and goldmark offers no toggle for it as a workaround.
How many seconds do we have of this precious life?
I do like working out of Emacs and not switching around to do stuff, so I found an old Ruby script I wrote to make generating a Hugo post with all the stuff particular to my setup and worked with a co-pilot to convert it to a lisp package.
When you invoke hpost-new
it prompts for whether to follow the “daily” or regular post style, then asks for title, tags, and description, then plops the new file into the right place and opens it for editing.
This goes in ~/.config/doom/lisp
:
1;;; hpost.el --- Create new Hugo posts from Emacs -*- lexical-binding: t; -*-
2
3;; Adjust this to point at your Hugo or ox‑hugo content directory.
4(defcustom hpost-posts-dir "~/blog/content/posts/"
5 "Directory where new Hugo posts are written."
6 :type 'directory :group 'hpost)
7
8(defun hpost--slugify (title)
9 "Convert TITLE to a URL‑friendly slug."
10 (let* ((s (downcase title))
11 (s (replace-regexp-in-string "[^a-z0-9]+" "-" s))
12 (s (replace-regexp-in-string "^-\|-$" "" s)))
13 (truncate-string-to-width s 60 nil nil ""))) ; hard cap at 60 chars
14
15(defun hpost--today () (format-time-string "%Y-%m-%d"))
16(defun hpost--now () (format-time-string "%Y-%m-%dT%H:%M:%S%z"))
17
18;;;###autoload
19(defun hpost-new (title tags summary &optional daily)
20 "Create a new Hugo post.
21
22Interactively prompts for TITLE, TAGS (comma‑delimited), and SUMMARY.
23With prefix arg, treat it as a daily note (pre‑sets title and tags)."
24 (interactive
25 (let* ((daily (y-or-n-p "Daily note? "))
26 (title (if daily
27 (format "Daily notes for %s" (hpost--today))
28 (read-string "Title: ")))
29 (tags (if daily "journal"
30 (read-string "Tags (comma): ")))
31 (summary (read-string "Summary: ")))
32 (list title tags summary daily)))
33 (let* ((slug (if daily "daily-notes" (hpost--slugify title)))
34 (fname (concat (hpost--today) "-" slug ".md"))
35 (path (expand-file-name fname hpost-posts-dir)))
36 (when (file-exists-p path)
37 (user-error "File %s already exists" fname))
38 (with-temp-buffer
39 (insert (format
40"---
41title: \"%s\"
42date: %s
43draft: true
44tags:%s
45summary: \"%s\"
46---
47
48" title (hpost--now)
49 (mapconcat (lambda (t) (format "\n- %s" (string-trim t)))
50 (split-string tags ",") "")
51 summary))
52 (write-region (point-min) (point-max) path))
53 (find-file path)
54 (message "New post created: %s" path)))
55
56(provide 'hpost)
57;;; hpost.el ends here
… and the config:
1;; --- Hugo post helper ---------------------------------------
2(use-package! hpost
3 :load-path "lisp/"
4 :defer t
5 :custom
6 (hpost-posts-dir "~/blog/content/posts/")
7 :config
8 ;; Optional keybinding: <leader> n h
9 (map! :leader
10 (:prefix ("n" . "notes")
11 :desc "New Hugo post" "h" #'hpost-new)))
It goes well with a few helpers I made to fire up or shut down the Hugo preview server within Emacs:
1(defun my-start-hugo-server ()
2 "Run Hugo server with live reloading."
3 (interactive)
4 (let* ((root (projectile-project-root))
5 (default-directory root))
6 (compile "hugo server -D --navigateToChanged" t)))
7
8(defun my-stop-hugo-server ()
9 "Stop Hugo server."
10 (interactive)
11 (kill-compilation))
12
13(map! :leader
14 (:prefix ("H" . "Hugo")
15 :desc "Start Hugo Server" "S" #'my-start-hugo-server
16 :desc "Stop Hugo Server" "s" #'my-stop-hugo-server))
So, SPC H S
to start the test server, and SPC n h
to start a new post. When I save the buffer, the preview server jumps to the newly written page in the browser.