# A Little Elisp for Hugo

> Published  Apr 04 2025, last updated Apr 04 2025  
> By Ryan Fleck <hello@this-site> and written without LLMs!  
> Original post at <https://ryanfleck.ca/2025/a-little-elisp-for-hugo/>  
> An article of astonishing quality and insight. Happy Hacking!


My work on this website and
[manuals.ryanfleck.ca](https://manuals.ryanfleck.ca) relies heavily on
the [hugo](https://gohugo.io) static site generator and the
[ox-hugo](https://ox-hugo.scripter.co/) Emacs package. Here are a few
short, fun automations to remove some of the (very limited) tedium
from developing *hugo* websites!

{{< toc >}}

# Easily Create New Posts

I wrote this *today* so I didn't have to go to the terminal to type
`hugo new posts/the-year/the-filename.md` when I wanted to jot down a
new idea. I can now use `M-x r/new-article`, enter the title in a
plain manner without dashes, and have the new article file immediately
open in front of me and ready to write in.

First, include the great [magnars/s.el](https://github.com/magnars/s.el)
string manipulation library.

```elisp
(require 's)
```

First we need a function to turn our titles into *file slugs*:

```elisp
(defun slugify (in)
  "Returns a formatted 'slug' type filename string"
  ;; Remove all special characters and replace whitespace with dashes
  (s-trim
   (s-replace " " "-"
    (s-collapse-whitespace
        (replace-regexp-in-string "[^a-zA-Z0-9 -]" ""
            (s-downcase in))))))

;; Tests

 (slugify "Wow this is '$$' great!!")
   ;> "wow-this-is-great"

 (slugify "What if-already there-are-dashes")
   ;> "what-if-already-there-are-dashes"

 (slugify "ALI&EEN ((MODE))")
   ;> "alieen-mode"
```

Here's the meat and potatoes. I'll break it down below:

```elisp
(defun r/new-post ()
  "Creates a new post in the current-year's directory for ryanfleck.ca"
  (interactive)
  (let* ((year (format-time-string "%G"))
         (name (slugify (read-string "Enter a post name (will be slugified): ")))
         (filename (s-append ".md" name))
         (new-post-path (format "posts/%s/%s" year filename))
         (full-post-filepath
           (concat "~/Documents/ryanfleck.ca/content/" new-post-path)))

    (when (not (s-ends-with? ".md" filename))
      (error "Filename must end in .md"))

    (when (not (file-exists-p "~/bin/hugo"))
      (error "Hugo binary not found in ~/bin"))

    (when (not (file-exists-p "~/Documents/ryanfleck.ca/config.toml"))
      (error "Cannot find hugo site 'ryanfleck.ca' in ~/Documents"))

    ;; Try to create a new post with the 'hugo' binary in ~/bin
    (message
     (shell-command-to-string
      (concat "cd ~/Documents/ryanfleck.ca && ~/bin/hugo new " new-post-path)))
    (message (concat "Created new post: " new-post-path))

    ;; Open the generated post.
    (if (file-exists-p full-post-filepath)
        (find-file full-post-filepath)
      (error (concat "Couldn't find new post at" full-post-filepath)))))
```

This first bit defines an interactive function we can call from
anywhere within Emacs. The docstring is visible when you invoke `M-x
r/new-post`, and `(interactive)` is essentially just a mark to let
Emacs know you want to execute this function interactively.

```elisp
(defun r/new-post ()
  "Creates a new post in the current-year's directory for ryanfleck.ca"
  (interactive)
```

This `let*` statement enables the user to use each binding as they are
assigned. By default, `let` assigns all bindings at once and does not
support this. Here we gather the year, filename, and prepare the paths
to the new post and eventual filepath.

```elisp
(let* ((year (format-time-string "%G"))
       (name (slugify (read-string "Enter a post name (will be slugified): ")))
       (filename (s-append ".md" name))
       (new-post-path (format "posts/%s/%s" year filename))
       (full-post-filepath
         (concat "~/Documents/ryanfleck.ca/content/" new-post-path)))
```

Before we execute our command line command, we ought to verify that
the hugo binary, the site we normally work on, and the filename all
seem correct.

```elisp
(when (not (s-ends-with? ".md" filename))
  (error "Filename must end in .md"))

(when (not (file-exists-p "~/bin/hugo"))
  (error "Hugo binary not found in ~/bin"))

(when (not (file-exists-p "~/Documents/ryanfleck.ca/config.toml"))
  (error "Cannot find hugo site 'ryanfleck.ca' in ~/Documents"))
```

Given that all the above is correct, we run `hugo new` and copy out
the result to the messages buffer. Following this, we open the file. *Easy peasy!*

```elisp
;; Try to create a new post with the 'hugo' binary in ~/bin
(message
 (shell-command-to-string
  (concat "cd ~/Documents/ryanfleck.ca && ~/bin/hugo new " new-post-path)))
(message (concat "Created new post: " new-post-path))

;; Open the generated post.
(if (file-exists-p full-post-filepath)
    (find-file full-post-filepath)
  (error (concat "Couldn't find new post at" full-post-filepath)))))
```

# Serving Your Drafts

Here's a very useful one. Hate reaching to the terminal, navigating to
the project directory, and typing `hugo serve --gc --minify -D`? Fear
no more, here's a little elisp snippet for you.

```elisp
(defun run-hugo-command (cmd)
  "Runs a command at the current project root. For instance, pass 'serve --gc -D'"
  ;; Runs a hugo server against the current hugo site.
  (let ((this-directory (file-name-directory (buffer-file-name))))

    (when (not (projectile--directory-p this-directory))
      (error "Not a hugo site or projectile not initialized."))

    ;; Ensure 'default-directory' is set to the project root.
    (let ((default-directory (projectile-project-root this-directory)))
      (when (not (file-exists-p (concat default-directory "/config.toml")))
        (error "This command should be run in a hugo site directory."))

      ;; Split the window and run the passed hugo command.
      (split-window-sensibly)
      ;; Docs here recommend 'start-process' instead:
      (async-shell-command (concat "~/bin/hugo " cmd) ; command
                           (format "Hugo (%s)" cmd) ; standard buffer name
                           (format "Hugo Errors (%s)" cmd) ; error buffer name
                           ))))

(defun r/hugo-server-d ()
  (interactive)
  (run-hugo-command "serve --gc -D"))

(defun r/hugo-gc ()
  (interactive)
  (run-hugo-command "--gc"))
```

# Transforming Org to Markdown

The [ox-hugo](https://ox-hugo.scripter.co/) package provides the great
utility of automatically converting your org-mode files to markdown as
you work on them. Hugo's built-in support for `.org` files is alright,
but not fantastic. To overcome this, install with
[setup.el](https://www.emacswiki.org/emacs/SetupEl) or your choice of
package management functions.


```elisp
(setup (:package ox-hugo) (:load-after ox))
```

The rest of the configuration is in the header. A file like `clj.org`
will become `clj.md` - the important properties to set are the
`hugo_base_dir` and `hugo_section` properties which dictate where your
generated *markdown* files will land.

```org
#+LAYOUT: docs-manual
#+TITLE: Clojure
#+SUMMARY: Enterprise grade magick.
#+PROPERTY: header-args:clojure :exports both :eval yes :results value scalar

# --- Ox-Hugo Properties ---
#+hugo_base_dir: ../../
#+hugo_section: languages
#+hugo_custom_front_matter: :toc true :summary "Enterprise grade magick." :chapter true
#+hugo_custom_front_matter: :aliases '("/clj/" "/clojure/" "/clj" "/cljd" "/cljs")
#+hugo_custom_front_matter: :warning "THIS FILE WAS GENERATED BY OX-HUGO, DO NOT EDIT!"
#+hugo_level_offset: 0

```

To export once, type `C-c C-e H H`. I like this to be done for me on
save, so add the [following elisp](https://ox-hugo.scripter.co/doc/auto-export-on-saving/)
to a file called `.dir-locals.el` at your project root:

```elisp
(("content-org/"
  . ((org-mode . ((eval . (org-hugo-auto-export-mode)))))))
```



> Thank you for reading!  
> Find more content at <https://ryanfleck.ca/>  
> Source page: <https://ryanfleck.ca/2025/a-little-elisp-for-hugo/>  
> Site index: [llms.txt](https://ryanfleck.ca/llms.txt)