This is my literate config for Doom Emacs. It makes extensive use of helper functions to embed/automate common workflows.

A “personal menu structure” underneath a custom leader gives me the ability to organize my keybinds logically according to how they fit into my workflows, instead of based on what feature/package/etc. implements them.

I’ve often felt inspired by other folks’ emacs configs, so I published mine in the hopes it similarly inspires others.

Be warned though, I’m no Emacs expert, so there might be some wrong conventions about the place.


This is a personal config. (My name is hardcoded in it, for one thing.) So feel free to be inspired, but you should not attempt to directly use this config.

Instead, I recommend you copy/paste code or ideas into your own config!

The following main principles underlie my config design:

Custom leader

All keybindings are defined behind a custom leader, M-SPC by default (only available in normal mode).

my/ prefix

All variables/functions are defined with the my/ prefix, e.g. my/notes-directory or my/concatd.

Conventional folder structure

This config is designed to work with a particular folder structure:


The exact paths can be configured by adjusting variables in the config. (Use M-SPC c r to tangle and reload the config after changes.)


For blogging:

  • Hugo v0.81
  • (optional) PlantUML in ~/bin/plantuml.jar

These have to be enabled are enabled in ~/.doom.d/init.el:

(org +roam)

This has to be in ~/.doom.d/packages.el:

(package! ox-hugo :recipe (:host github :repo "luketurner/ox-hugo" :branch "master"))

In this case, I’m using my own fork of ox-hugo that has a simple patch to remove no-longer-needed underscore escaping in URLs (see: https://github.com/kaushalmodi/ox-hugo/issues/308).

Apparently this is only an issue for certain browser/website combinations that don’t un-escape them.

However, my fork is pretty far behind ox-hugo at this point, I should probably switch back to the main project soon.


To use this config for the first time (e.g. with a brand-new Emacs install):

  1. Make sure the the Shared and Config management variables are set to the correct values.
  2. Ensure this .org file is saved in the location specified by my/config-literate-file.
  3. Run the following code block with C-c C-c

Then restart Emacs to load the new config!

Once bootstrapped, reload the config without restarting with M-SPC c r.


This section defines shared functions, variables, and macros that are used throughout the config.

Think of it as the “standard library” for the rest of the config.

(defvar my/name "Luke Turner")
(defvar my/email "redacted")

(defvar my/leader "M-SPC")

(defun my/concatd (&rest paths)
  "Accepts any number of arguments, concatenating them together
   into a path with appropriate directory separators. The last
   argument is assumed to be a filename (i.e. it will not have a
   path separator added to the end). A path ending in a path
   separator can be generated by adding a nil to the end of your
   path (indicating the filename part of the path is empty.)"
  (let ((paths (reverse paths)))
    (seq-reduce (lambda (m x) (concat (file-name-as-directory x) m))
                (cdr paths)
                (car paths))))

(defvar my/home-directory "~")
(defvar my/project-directory (my/concatd my/home-directory "projects" nil))

(defmacro my/map! (p pname &rest forms)
  "Convenience macro for mapping keys underneath my/leader."
  `(map! :prefix (,(concat my/leader " " p) . ,pname) ,@forms))

(require 'seq) ; make sure seq is loaded
(require 'which-key) ; sometimes needed for which-key-idle-delay to be detected?

(setq user-full-name my/name
      user-mail-address my/email)

(setq doom-font (font-spec :family "Fira Code" :size 14))
(setq doom-theme 'doom-one)

(setq display-line-numbers-type t)
(setq which-key-idle-delay 0.1)
(setq confirm-kill-emacs nil)

(after! org
  (setq org-agenda-custom-commands '())
  (setq org-capture-templates '()))

(after! org-roam
  (setq org-roam-capture-templates '()))

; Ensure leader is unbound (may not be necessary, depending on your leader)
(map! my/leader nil)

Notes system

My notes system is built around org-roam, with additional conventions layered on top. Put simply:

  • A note is a single Org file stored in ~/notes.
  • Notes are organized by linking them together.
  • Notes can be published as blog posts (see Blogging)

A couple tips for interacting with notes:

  • Use M-SPC n f for both opening and creating notes. (If you try to “find” a note that doesn’t exist, it will be created.)
  • When creating a personal note, use the note template. If note is intended to be published to the blog, use blog template to pre-fill frontmatter.
  • Use M-SPC n s to do a ripgrep-search through all your notes.
  • Use M-SPC n a t to open a list of all the TODOs in your notes.

Besides the above, the Doom standard SPC n prefix has has a bunch of Org notes keybindings that can operate on ~/notes.

(defvar my/note-directory (my/concatd my/home-directory "notes" nil))

(my/map! "n" "notes"
   :desc "find note" "f" 'my/note-find
   :desc "search notes" "s" 'my/note-search
   :desc "daily agenda" "a" 'my/note-agenda)

(defun my/note-find () (interactive) (org-roam-find-file))
(defun my/note-search () (interactive) (+default/org-notes-search))
(defun my/note-agenda () (interactive) (org-agenda))

(setq org-directory my/note-directory)
(setq +org-capture-notes-file "inbox.org")

(after! org-roam
  (setq org-roam-directory org-directory)
  (add-to-list 'org-roam-capture-templates
               '("n" "note" plain (function org-roam--capture-get-point)
                 :file-name "%<%Y%m%d%H%M%S>-${title}"
                 :head "#+TITLE: ${title}\n"
                 :immediate-finish t)))

Config management

This section has the functionality for editing, tangling, and compiling this literate config.

The literate config file should be stored in ~/notes/config.org. It can be opened with M-SPC c o.

The tangled config file should be in ~/.doom.d/config.el. It will be created automatically when you tangle the literate config file. It can be opened with M-SPC c O. Any changes will be overwritten when you re-tangle.

There are three steps:

  1. Tangling: Extracting all the source code from the config.org to generate a config.el
  2. Compiling: Byte-compiling the config.el into a config.elc (improves startup time).
  3. (Re)loading: (Optional) Loads the config file directly into the running Emacs process without restarting.

There are functions for each stage of the process, plus convenience functions for doing multiple steps at once.

Use M-SPC c r to tangle, compile, and reload the config. Use M-SPC c R to tangle, compile, and restart Emacs.

Reloading the config without restarting the process can result in unexpected behavior. Old keybinds, for example, will continue to exist despite no longer being defined.

Restarting the process (M-SPC c R) is the most effective way to ensure things are consistent after a config change.

(defvar my/config-literate-file (expand-file-name "config.org" my/note-directory))
(defvar my/config-tangled-file (expand-file-name "config.el" doom-private-dir))

(my/map! "c" "config"
           :desc "tangle config" "t" 'my/config-tangle-compile
           :desc "tangle & reload" "r" 'my/config-tangle-compile-reload
           :desc "tangle & restart" "R" 'my/config-tangle-compile-restart
           :desc "open config" "o" 'my/config-find
           :desc "open tangled" "O" 'my/config-find-tangled)

(defun my/config-find () (interactive) (find-file my/config-literate-file))
(defun my/config-find-tangled () (interactive) (find-file my/config-tangled-file))

(defun my/config-tangle () (interactive) (org-babel-tangle-file my/config-literate-file my/config-tangled-file))
(defun my/config-compile () (interactive) (byte-compile-file my/config-tangled-file))
(defun my/config-reload () (interactive) (load (file-name-sans-extension my/config-tangled-file)))

(defun my/config-tangle-compile () (interactive) (my/config-tangle) (my/config-compile))
(defun my/config-tangle-compile-restart () (interactive) (my/config-tangle-compile) (doom/restart-and-restore))
(defun my/config-tangle-compile-reload () (interactive) (my/config-tangle-compile) (my/config-reload))


This section exposes keybindings for managing a personal blog. By default, the blog is assumed to use Hugo and be stored in ~/projects/blog.

Org files can be published to the blog (hosted on Github Pages) with the following steps:

  1. Export current file to blog: M-SPC b e
  2. Build static HTML: M-SPC b b
  3. Push to origin: M-SPC b p

This uses ox-hugo to do the export, and hugo to build the HTML.

Posts are configured using Org file-level properties. The list of supported properties is defined in ox-hugo’s docs.

Besides the default properties, the theme supports custom front-matter parameters:

  • summary
  • version

To generate a new Org document with all these properties pre-filled, create a new note using the blog capture template, like: M-SPC n f <blog title> RET b.

I created the custom theme lukes-hugo-theme specifically for this workflow, but the exported posts should (mostly) work with any Hugo theme.

For a visual explanation of the blogging process, reference my post about my Blogging workflow.

(defvar my/blog-directory (my/concatd my/project-directory "blog" nil))
(defvar my/blog-posts-directory (my/concatd my/blog-directory "content" "posts" nil))

(my/map! "b" "blog"
   :desc "open blog" "o" 'my/blog-project-open
   :desc "export this file" "e" 'my/blog-export-file
   :desc "serve" "s" 'my/blog-serve
   :desc "serve drafts" "S" 'my/blog-serve-drafts
   :desc "build" "b" 'my/blog-build
   :desc "commit and push" "p" 'my/blog-push)

(defun my/blog-export-file ()
  "Exports the current Org file to the blog."
  (require 'ox-hugo)
  (let ((notename (my/concatd my/blog-posts-directory (concat (file-name-base (buffer-file-name)) ".md"))))
    (org-export-to-file 'hugo notename)))

(defun my/blog-serve ()
  "Launches a server for local blog development (no drafts)."
  (let ((default-directory my/blog-directory))
    (shell-command "hugo serve &")))

(defun my/blog-serve-drafts ()
  "Launches a server for local blog development (with drafts)."
  (let ((default-directory my/blog-directory))
    (shell-command "hugo serve -D &")))

(defun my/blog-build ()
  "Builds the blog (no drafts)"
  (let ((default-directory my/blog-directory))
    (shell-command "hugo")))

(defun my/blog-push ()
  "Commits and pushes all changed files."
  (let ((default-directory my/blog-directory))
    (shell-command "git add .")
    (shell-command "git commit -a -m \"automatic update\"")
    (shell-command "git push origin")))

(defun my/blog-project-open ()
  "Opens the blog project."
  (projectile-add-known-project my/blog-directory)
  (+workspaces-switch-to-project-h my/blog-directory))

(defvar my/blog-post-head (concat "#+TITLE: ${title}\n#+BLOG: true\n#+DATE: %t\n#+HUGO_DRAFT: true\n#+HUGO_SLUG: ${slug}\n#+AUTHOR: " my/name "\n#+HUGO_TAGS: ${tags}\n#+HUGO_BASE_DIR: " my/blog-directory "\n#+HUGO_CUSTOM_FRONT_MATTER: :version 0 :summary ${summary}\n"))

(after! org
  (setq org-plantuml-jar-path (expand-file-name "~/bin/plantuml.jar")))

(after! ox-hugo
 (setq org-hugo-default-static-subdirectory-for-externals "attachments"))

(after! org-roam
  (add-to-list 'org-roam-capture-templates
               `("b" "blog post" plain (function org-roam--capture-get-point)
                 :file-name "%<%Y%m%d%H%M%S>-${title}"
                 :head ,my/blog-post-head
                 :immediate-finish t