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

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 string manipulation library.

(require 's)

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

(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:

(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.

(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.

(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.

(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!

;; 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.

(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 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 or your choice of package management functions.

(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.

#+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 to a file called .dir-locals.el at your project root:

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