Blogging with org-mode

I do almost all my note-taking in Emacs org-mode, so naturally I also prefer to write my blog posts in it. As for my ox-hugo blogging flow, I use the less preferred method: one org file per post, the consequence is I cannot just copy-paste the org capture setup provided by the doc site. I also setup my posts in a subdirectory beneath the HUGO_BASE_DIR:

1
tree -n  ..
Code Snippet 1: my content directory
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
..
├── about
│   └── about.org
├── archives.org
├── configuring-neovim
│   └── configuring-neovim.org
├── emacs-avy
│   ├── avy.gif
│   └── emacs-avy.org
├── emacs-builtin-use-package
│   ├── emacs-builtin-use-package.org
│   ├── prefix-key.png
│   └── which-key-mode.png
├── emacs-evil
│   └── emacs-evil.org
├── emacs-general
│   └── emacs-general.org
├── emacs-sane-defaults
│   ├── default-emacs.png
│   ├── emacs-after.png
│   ├── emacs-dired.png
│   ├── emacs-eshell.png
│   ├── emacs-sane-defaults.org
│   └── emacs-tetris.png
├── emacs-shell
│   ├── emacs-shell.org
│   ├── eshell-command-form.png
│   ├── eshell-elisp-form.png
│   └── eshell.png
├── emacs-version-control
│   ├── emacs-magit-status.png
│   └── emacs-version-control.org
├── emacs-vertico
│   ├── consult-buffer.png
│   ├── consult-flymake.png
│   ├── consult-outline.png
│   ├── consult-yank-pop.png
│   ├── corfu-completion.png
│   ├── emacs-vertico.org
│   ├── emacs-vertico.png
│   ├── emacs-without-vertico.png
│   ├── marginalia-describe-variable.png
│   ├── marginalia-files.png
│   ├── marginalia-find-file-with-icons.png
│   ├── nerd-icons-corfu.png
│   ├── orderless-default.png
│   ├── vertico-buffer-mode.png
│   ├── vertico-flat-mode.png
│   ├── vertico-grid-mode.png
│   ├── vertico-indexed-mode.png
│   ├── vertico-quick.png
│   └── vertico-reverse-mode.png
├── github-action-hugo-emacs
│   ├── action-secrets-and-variables.png
│   ├── deploy-github.png
│   └── github-action-hugo-emacs.org
├── hugo-blogging-org-capture-templates
│   ├── hugo-blogging-org-capture-templates.org
│   └── transient.png
├── hyprland
│   ├── applications.gif
│   ├── hyprland.org
│   └── hyprland.png
├── i3wm
│   ├── i3wm-2025-06-23 23:42:18.gif
│   ├── i3wm-2025-06-23_22-28.png
│   ├── i3wm-dependency-2025-06-24_00-05.png
│   ├── i3wm-scratch-2025-06-23 22-53.gif
│   └── i3wm.org
├── learning-rust
│   ├── learning-rust.org
│   └── rust-perfect-number.png
├── literate-configuration
│   ├── github-pages-deployed.png
│   ├── literate-configuration.org
│   ├── literate-emacs-screenshot.png
│   └── org-edit-special.png
├── modular-guix-system-configuration
│   ├── EFI-partition.png
│   ├── TRAMP.png
│   ├── btrfs-subvolumes.png
│   ├── cow-store.png
│   ├── filesystem.png
│   ├── linux-partition.png
│   ├── modular-guix-system-configuration.org
│   ├── mount-btrfs.png
│   ├── mounting-subvolumes.png
│   └── swapfile-creation.png
├── notmuch-mail-emacs
│   ├── full.png
│   └── notmuch-mail-emacs.org
├── org-mode-workflow
│   ├── emacs-dashboard.png
│   ├── org-agenda-custom-commands-upcoming.png
│   ├── org-agenda-custom-commands-work.png
│   ├── org-agenda-custom-incomplete.png
│   ├── org-agenda-custom-next-three-days.png
│   ├── org-agenda-custom-today.png
│   ├── org-capture-1.png
│   ├── org-capture-2.png
│   ├── org-capture-firefox.png
│   ├── org-capture-with-template-1.png
│   ├── org-capture-with-template-2.png
│   ├── org-capture-with-template-3.png
│   ├── org-capture-with-template-4.png
│   ├── org-mode-workflow.org
│   ├── org-modern-agenda.png
│   ├── org-modern.png
│   ├── org-protocol-capture-firefox.png
│   ├── task-flow.svg
│   └── vanilla-emacs-org-agenda.png
├── projects
│   └── projects.org
├── search.org
├── shell-tips-and-tricks
│   └── shell-tips-and-tricks.org
├── syncthing-is-underrated
│   ├── syncthing-is-underrated.org
│   └── syncthing-web-2025-06-27_00-11.png
├── terminal-multiplexer
│   ├── gnu-screen-2.png
│   ├── gnu-screen.png
│   └── terminal-multiplexer.org
├── theming-emacs
│   ├── default-emacs.png
│   ├── emacs-doom-theme-modeline-and-dashboard.png
│   ├── emacs-wombat.png
│   └── theming-emacs.org
└── why-use-mpd
    ├── cantata-2025-06-26_22-54.png
    ├── mpc-2025-06-26_23-41.png
    ├── ncmpcpp-2025-06-26_22-44.png
    └── why-use-mpd.org

26 directories, 107 files

Custom org-capture-templates

First off, org-capture-templates is just like its name; it is a template for creation of new entries. It is used by org-mode, which is a killer feature of Emacs. Initially it was design to capture notes with little interruption1. But since it was all Emacs Lisp, we can modify it with ease.

basic template

1
2
3
4
5
(setq org-capture-templates
      '(("t" "Todo" entry (file+headline "~/org/gtd.org" "Tasks")
         "* TODO %?\n  %i\n  %a")
        ("j" "Journal" entry (file+datetree "~/org/journal.org")
         "* %?\nEntered on %U\n  %i\n  %a")))
Code Snippet 2: from the manual

org-capture-templates is a list of:

keys
in the example, t is for todo entry and j is for journal.
description
usually a one-liner describing what kind of capture the key is.
type
the type of the entry, here entry is an org node with a headline.
target
where the capture should be placed.
template
the template itself2.

So I need to tweak it a bit in order to automatically create a file within a subdirectory in my blog content (using the same name to make it easier). Not only that, ox-hugo use an org meta-data for hugo front-matter3. Each new files created must be started with these metadata (at minimal):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#+options: ':nil -:nil ^:{} num:nil toc:nil
#+author: Kristian Alexander P
#+creator: Emacs 29.2 (Org mode 9.6.15 + ox-hugo)
#+hugo_section: posts
#+hugo_base_dir: ../../
#+date: <2024-03-03 Sun>
#+title: Hugo blog org-capture-templates
#+description: My blogging workflow
#+hugo_tags: hugo emacs org
#+hugo_categories: emacs
#+hugo_auto_set_lastmod: t
#+startup: inlineimages
Code Snippet 3: ox-hugo metadata

Some metadata will be different for each capture; title, description, hugo_tags, and hugo_categories. dates should be set as the capture date, the other will be needing a user input, including the filename, for the org-capture process.

Those will be set as the template part. As for the target, I’m using a simple Emacs Lisp function:

1
2
3
4
5
(defun +config/create-new-blog-post ()
  "Create new blog post path."
  (interactive)
  (let ((name (read-string "Filename: ")))
    (concat +config/blog-directory "/content-org/" (format "%s" name) "/" (format "%s.org" name))))
Code Snippet 4: my targeting function

This function is just a basic input/output; it will ask for a file name, and then it will concatenate it as a valid file path, here subtituted as a variable +config/blog-directory

1
2
3
(when (file-directory-p (expand-file-name "alexforsale.github.io" org-directory))
  (customize-set-variable '+config/blog-directory
                          (expand-file-name "alexforsale.github.io" org-directory)))
Code Snippet 5: the variable definition

Basically it will look for a directory named “alexforsale.github.io” (it’s my github-page repository) inside the variable org-directory, which is also should be set.

The actual template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
(add-to-list 'org-capture-templates
             '("b" "(b)log post" plain
               (file +config/create-new-blog-post)
               "\
#+options: ':nil -:nil ^:{} num:nil toc:nil
#+author: %n
#+creator: Emacs %(eval emacs-version) (Org mode %(eval org-version) + ox-hugo)
#+hugo_section: posts
#+hugo_base_dir: ../../
#+date: %t
#+title: %^{title}
#+hugo_draft: true
#+description: %^{description}
#+hugo_tags: %^{tags}
#+hugo_categories: %^{categories}
#+hugo_auto_set_lastmod: t
#+startup: inlineimages\n%?" :unnarrowed t :jump-to-captured t))
Code Snippet 6: my capture template

This will create a draft post, to publish it set the draft to false. Some template expansions I use:

%n
this will expand to the variable user-full-name.
%t
date.
%^{title}, %{description}, %^{tags}, and %^{categories}
will prompt for the user for each metadata.
\n
is for newline.
%?
will be the point location.

Usually, org-capture is not bound to any keys, the recommended way is to bind it to C-c c4. So to use this template the keybinding is C-c c b.

1
2
3
(global-set-key (kbd "C-c l") #'org-store-link)
(global-set-key (kbd "C-c a") #'org-agenda)
(global-set-key (kbd "C-c c") #'org-capture)
Code Snippet 7: the recommended keybindings

Preview hugo blog locally

Before pushing each commits to my github repository, I’d view my blog in my local machine, With hugo this can be done by running:

1
hugo server --buildDrafts --navigateToChanged
Code Snippet 8: hugo server

from within the HUGO_BASE_DIR directory. This can be done from a terminal emulator, or, since I’m using Emacs, I can run it using async-shell-command, which is actually shell-command, but adds a & at the end of the command to run it asynchronously.

1
(async-shell-command "hugo server --buildDrafts --navigateToChanged &" "*hugo*" "*hugo-error*")
Code Snippet 9: running hugo server within emacs

The *hugo* argument is the output-buffer, and *hugo-error* is the error-buffer. But since the hugo server command has many other flags, I use an external package called transient5 to toggle each flags.

1
hugo server --help
Code Snippet 10: hugo server help
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
Hugo provides its own webserver which builds and serves the site.
While hugo server is high performance, it is a webserver with limited options.

The `hugo server` command will by default write and serve files from disk, but
you can render to memory by using the `--renderToMemory` flag. This can be
faster in some cases, but it will consume more memory.

By default hugo will also watch your files for any changes you make and
automatically rebuild the site. It will then live reload any open browser pages
and push the latest content to them. As most Hugo sites are built in a fraction
of a second, you will be able to save and see your changes nearly instantly.

Usage:
  hugo server [command] [flags]
  hugo server [command]

Aliases:
  server, serve

Available Commands:
  trust       Install the local CA in the system trust store

Flags:
      --appendPort               append port to baseURL (default true)
  -b, --baseURL string           hostname (and path) to the root, e.g. https://spf13.com/
      --bind string              interface to which the server will bind (default "127.0.0.1")
  -D, --buildDrafts              include content marked as draft
  -E, --buildExpired             include expired content
  -F, --buildFuture              include content with publishdate in the future
      --cacheDir string          filesystem path to cache directory
      --cleanDestinationDir      remove files from destination not found in static directories
  -c, --contentDir string        filesystem path to content directory
      --disableBrowserError      do not show build errors in the browser
      --disableFastRender        enables full re-renders on changes
      --disableKinds strings     disable different kind of pages (home, RSS etc.)
      --disableLiveReload        watch without enabling live browser reload on rebuild
      --enableGitInfo            add Git revision, date, author, and CODEOWNERS info to the pages
      --forceSyncStatic          copy all files when static is changed.
      --gc                       enable to run some cleanup tasks (remove unused cache files) after the build
  -h, --help                     help for server
      --ignoreCache              ignores the cache directory
  -l, --layoutDir string         filesystem path to layout directory
      --liveReloadPort int       port for live reloading (i.e. 443 in HTTPS proxy situations) (default -1)
      --minify                   minify any supported output format (HTML, XML etc.)
  -N, --navigateToChanged        navigate to changed content file on live browser reload
      --noChmod                  don't sync permission mode of files
      --noHTTPCache              prevent HTTP caching
      --noTimes                  don't sync modification time of files
  -O, --openBrowser              open the site in a browser after server startup
      --panicOnWarning           panic on first WARNING log
      --poll string              set this to a poll interval, e.g --poll 700ms, to use a poll based approach to watch for file system changes
  -p, --port int                 port on which the server will listen (default 1313)
      --pprof                    enable the pprof server (port 8080)
      --printI18nWarnings        print missing translations
      --printMemoryUsage         print memory usage to screen at intervals
      --printPathWarnings        print warnings on duplicate target paths etc.
      --printUnusedTemplates     print warnings on unused templates.
      --renderSegments strings   named segments to render (configured in the segments config)
      --renderStaticToDisk       serve static files from disk and dynamic files from memory
      --templateMetrics          display metrics about template executions
      --templateMetricsHints     calculate some improvement hints when combined with --templateMetrics
  -t, --theme strings            themes to use (located in /themes/THEMENAME/)
      --tlsAuto                  generate and use locally-trusted certificates.
      --tlsCertFile string       path to TLS certificate file
      --tlsKeyFile string        path to TLS key file
      --trace file               write trace to file (not useful in general)
  -w, --watch                    watch filesystem for changes and recreate as needed (default true)

Global Flags:
      --clock string               set the clock used by Hugo, e.g. --clock 2021-11-06T22:30:00.00+09:00
      --config string              config file (default is hugo.yaml|json|toml)
      --configDir string           config dir (default "config")
  -d, --destination string         filesystem path to write files to
  -e, --environment string         build environment
      --ignoreVendorPaths string   ignores any _vendor for module paths matching the given Glob pattern
      --logLevel string            log level (debug|info|warn|error)
      --noBuildLock                don't create .hugo_build.lock file
      --quiet                      build in quiet mode
  -M, --renderToMemory             render to memory (mostly useful when running the server)
  -s, --source string              filesystem path to read files relative from
      --themesDir string           filesystem path to themes directory

Use "hugo server [command] --help" for more information about a command.

Then I just use transient-define-prefix to create the command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(use-package transient
  :config
  (transient-define-prefix +config/transient-hugo-server ()
    "Run hugo server with `transient'."
    :man-page "hugo-server"
    ["Options"
     ("q" "quit" transient-quit-all)
     ("-D" "Build drafts" "--buildDrafts")
     ("-E" "Build expired" "--buildExpired")
     ("-F" "Build future" "--buildFuture")
     ("-d" "Debug" "--debug")
     ("-B" "Disable build errors on browser" "--disableBrowserError")
     ("-c" "Clean destination dir" "--cleanDestinationDir")
     ("-e" "Enable Git info" "--enableGitInfo")
     ("-F" "enable full re-renders on changes" "--disableFastRender")
     ("-f" "Force sync static files" "--forceSyncStatic")
     ("-g" "enable to run some cleanup tasks" "--gc")
     ("-m" "Minify any supported output format" "--minify")
     ("-C" "No chmod" "--noChmod")
     ("-T" "Don't sync modification time of files" "--noTimes")
     ("-I" "Print missing translation" "--printI18nWarnings")
     ("-M" "Print memory usage" "--printMemoryUsage")
     ("-P" "Print warning on duplicate target path" "--printPathWarnings")
     ("-q" "Quiet" "--quiet")
     ("-v" "Verbose" "--verbose")
     ("-w" "Watch filesystem for changes" "--watch")]
    ["Action"
     ("s" "hugo server" +config/start-hugo-server)]))
Code Snippet 11: install transient with use-package and define our hugo server command, see their showcase to learn how to use transient. I use this heavily in the past.

Note that the “Action” (s) is +config/start-hugo-server which we need to define:

1
2
3
4
5
6
7
8
(defun +config/start-hugo-server (args)
  "Start hugo server in `+config/blog-directory'."
  (interactive (list (transient-args '+config/transient-hugo-server)))
  (if (not (executable-find "hugo"))
      (message "hugo executable not found")
    (let ((default-directory +config/blog-directory)
          (command "hugo server"))
      (async-shell-command (mapconcat #'identity `(,command ,@args) " ") "*hugo*" "*hugo-error*"))))
Code Snippet 12: the function

This function will run hugo server, with additional args which will be provided by the transient command.

  • the mapconcat will apply the first argument to each element of the second arguments, for example:

    1
    
    (mapconcat #'identity '("abc" "def" "ghi") ". ")
    
    Code Snippet 13: mapconcat example
    1
    
    "abc. def. ghi"
    
  • the identity simply returns the arguments unchanged.

    Figure 1: transient in action

    Figure 1: transient in action