Last update: November 12, 2026
In my ongoing series of literate config files, I am now posting my Doom Emacs config. I switched to Doom from my hand-crafted Emacs config some time ago, and I have been really enjoying it. Hope you find it useful!
As usual, the post below is included directly from my live doom.org file.
If you are interested in writing your own Literate Config files, check out my book Literate Config on Leanpub!
Maintaining this configuration#
Files and load order#
I make all my changes in this file, then run C-c C-v t (org-babel-tangle) (or just save it - see Automatic tangling) to generate the Lisp files. If you want to change something, this is the place to do it! I keep package declarations next to the code that uses them, with :tangle packages.el to send them to the right file. Other Lisp blocks go to config.el by default (this is configured at the top of doom.org with #+property: header-args:emacs-lisp :tangle config.el).
| Output | Purpose | Apply changes |
|---|---|---|
init.el | Enabled Doom modules and flags | doom sync, restart |
packages.el | Additional packages and recipe overrides | doom sync, restart |
config.el | Personal settings, commands, and bindings | Restart or M-x doom/reload |
I let Emacs manage custom.el separately. It holds settings such as trusted themes and file-local variables that I’ve approved. I try to keep it minimal, so that doom.org is almost always the authoritative source of configuration
Some variables need their values before a package loads; for settings that depend on the package’s definitions, I use after! or use-package!. Structure setters and Transient macros need a little extra care: expanding them before their packages load can break startup, so I defer those too.
Dependencies and upgrades#
At the time of this update, I’m using Emacs 30.2, Org 9.8.9, and org-gtd 4.0.7. On my machine, Doom’s modules live in ~/.emacs.d/sources/doom+/modules/. When something doesn’t work as expected, I check their .doommodule, packages.el, and config.el files to see what my installed version actually does. Online examples sometimes refer to flags that no longer exist, such as Org’s +hugo.
I use local development checkouts of some modules, like my own ox-leanpub, so their recipe paths need to point to those repositories (see Leanpub books for an example). Some features also need external tools: tectonic, typst, elvish, pandoc, and the compilers or interpreters for the languages I use. The Reveal browser helper (see Reveal presentations) and Outlook capture code (see Capture templates) need macOS.
Generated file headers#
In each generated file, add a header indicating not to edit it by hand.
;;; init.el -*- lexical-binding: t; -*-
;; DO NOT EDIT THIS FILE DIRECTLY.
;; Generated from doom.org with org-babel-tangle (C-c C-v t).;;; packages.el -*- lexical-binding: t; no-byte-compile: t; -*-
;; DO NOT EDIT THIS FILE DIRECTLY.
;; Generated from doom.org with org-babel-tangle (C-c C-v t).;;; config.el -*- lexical-binding: t; -*-
;; DO NOT EDIT THIS FILE DIRECTLY.
;; Generated from doom.org with org-babel-tangle (C-c C-v t).Enabled Doom modules#
I list only the modules I enable here. Doom’s init.example.el and module documentation cover all the options. I don’t use Evil mode, so it’s absent. Corfu handles completion in editing buffers, and Vertico handles the minibuffer. They seem to work quite well so far.
(doom!
:completion
(corfu +orderless +icons +dabbrev)
(vertico +icons)
:ui
doom
dashboard
hl-todo
(ligatures +extra)
modeline
nav-flash
ophints
(popup +defaults)
vi-tilde-fringe
window-select
zen
:editor
file-templates
snippets
word-wrap
:emacs
dired
electric
undo
vc
:term
vterm
:checkers
(syntax +childframe)
spell
:tools
ansible
debugger
(eval +overlay)
gist
lookup
lsp
(magit +forge)
pass
:os
(:if IS-MAC macos)
tty
:lang
common-lisp
emacs-lisp
(ess +lsp)
(go +lsp)
json
(latex +latexmk)
lua
markdown
(org +pretty +journal +roam +present)
plantuml
python
racket
rst
rust
(sh +lsp)
(yaml +lsp)
:config
(default +bindings +smartparens))Editor defaults#
I use Command as Meta on macOS and swap the X Meta/Super modifiers on Linux. I’m used to having the Emacs Meta key immediately to the left of the spacebar.
(setq user-full-name "Diego Zamboni"
user-mail-address "diego@zzamboni.org")
(cond (IS-MAC
(setq mac-command-modifier 'meta
mac-option-modifier 'alt
mac-right-option-modifier 'alt
mac-pass-control-to-system nil))
(IS-LINUX
(setq x-meta-keysym 'alt
x-super-keysym 'meta)))I like C-k to remove the whole line when I’m at its beginning. I also enable auto-save and backups, hide line numbers, and let Emacs exit without asking for confirmation.
(setq kill-whole-line t)
(setq display-line-numbers-type nil)
(setq auto-save-default t
make-backup-files t)
(setq confirm-kill-emacs nil)I use Desktop to restore my files and window layouts automatically, including desktops with a lock file. I don’t need separate workspace contexts, so I leave Doom’s workspaces module disabled.
;(after! auth-source
; (setq auth-sources (nreverse auth-sources)))
(desktop-save-mode 1)
(setq! desktop-load-locked-desktop t)Appearance#
Dashboard, fonts, and theme#
I pick a random splash image from splash/ each time Emacs starts. For fonts, I use Fira Code Nerd Font for code and Alegreya for prose, with the Spacemacs Light theme. Mixed Pitch lets me use a proportional font in Org while keeping code monospaced. I can adjust the font size with C-= and C--, or reset it with C-0.
The dashboard faces need a small tweak so the theme’s comment background doesn’t show through transparent banner images. I also turn off a few symbolic ligatures, keep the font ligatures, and show a word count in the modeline.
(let ((alternatives '("doom-emacs-bw-light.svg"
"doom-emacs-flugo-slant_out_purple-small.png"
"doom-emacs-flugo-slant_out_bw-small.png")))
(setq fancy-splash-image
(concat doom-user-dir "splash/"
(nth (random (length alternatives)) alternatives))))
(setq doom-font (font-spec :family "Fira Code Nerd Font" :size 16)
doom-variable-pitch-font (font-spec :family "Alegreya" :size 16))
(add-hook! 'org-mode-hook #'mixed-pitch-mode)
(setq mixed-pitch-variable-pitch-cursor nil)
(map! "C-=" #'doom/increase-font-size
"C--" #'doom/decrease-font-size
"C-0" #'doom/reset-font-size)(package! spacemacs-theme)(setq doom-theme 'spacemacs-light)
(custom-set-faces!
'(doom-dashboard-banner :inherit default)
'(doom-dashboard-loaded :inherit default))
(plist-put! +ligatures-extra-symbols
:and nil
:or nil
:for nil
:not nil
:true nil
:false nil
:int nil
:float nil
:str nil
:bool nil
:list nil)
(setq doom-modeline-enable-word-count t)Remove from the dashboard some sections I don’t use. Disabled for now to test how I feel about the new dashboard.
;(setq +dashboard-menu-sections (cl-subseq +dashboard-menu-sections 0 2))Navigation and editing#
Completion#
I use Corfu for completion at point and Vertico for minibuffer selection. Doom takes care of the surrounding pieces through its built-in corfu and vertico modules: Cape, Consult, Orderless, Marginalia, Embark, Prescient, icons, popup documentation, and snippets. Phew! The Emacs ecosystem has become extremely complex in this respect. I had to learn what these do, so here’s a summary:
- With Orderless, you can type space-separated terms in any order, or use
~termwhen I want fuzzy matching; - Prescient learns and ranks choices based on my behavior;
C-M-iasks for completion explicitly. ; I let Orderless do the matching. Corfu remembers my choices in editing buffers through its history extension.
(package! vertico-prescient)(use-package! vertico-prescient
:after vertico
:init
(setq vertico-prescient-enable-filtering nil
prescient-save-file (doom-profile-cache-dir t "prescient-save.el"))
:config
(vertico-prescient-mode 1)
(prescient-persist-mode 1))
(after! cape
;; Preserve Company's current-buffer-only fallback word search.
(setq cape-dabbrev-check-other-buffers nil))
(after! corfu
;; Preview candidates without committing them merely by continuing to type.
(setq corfu-preview-current t))For Lua and LaTeX, I use Company backends through Cape’s Company-to-CAPF adapter. That gives Corfu access to their specialized candidates without turning on Company mode. Emacs Lisp, Sly, ESS, Python, Racket, and LSP use native completion functions, including LSP completion in shell buffers. Doom also adds Yasnippet candidates so I can complete snippets through the same interface.
(package! company-lua :pin "29f6819de4d691e5fd0b62893a9f4fbc1c6fcb52")
(package! company-auctex :pin "9400a2ec7459dde8cbf1a5d50dfee4e300ed7e18")
(package! company-reftex :pin "42eb98c6504e65989635d95ab81b65b9d5798e76")
(package! company-math :pin "3eb006874e309ff4076d947fcbd61bb6806aa508")(defun zz/add-company-capfs (&rest backends)
"Add BACKENDS to this buffer through Cape without enabling Company mode."
(require 'cape)
(dolist (backend backends)
(let ((capf (intern (format "zz/capf-%s" backend))))
(unless (fboundp capf)
(defalias capf (cape-company-to-capf backend)))
(add-hook 'completion-at-point-functions capf 10 t))))
(defun zz/lua-completion-setup ()
"Retain Lua's built-in API completion without a language server."
(require 'company-lua)
(zz/add-company-capfs #'company-lua))
(add-hook 'lua-mode-hook #'zz/lua-completion-setup)
(defun zz/latex-completion-setup ()
"Retain AUCTeX and math completion alongside native CAPFs."
(require 'company-auctex)
(require 'company-math)
(zz/add-company-capfs #'company-auctex-environments
#'company-auctex-macros
#'+latex-symbols-company-backend))
(add-hook 'LaTeX-mode-hook #'zz/latex-completion-setup)
(defun zz/reftex-completion-setup ()
"Retain RefTeX label and citation completion."
(when reftex-mode
(require 'company-reftex)
(zz/add-company-capfs #'company-reftex-labels #'company-reftex-citations)))
(add-hook 'reftex-mode-hook #'zz/reftex-completion-setup)Buffers, search, and undo#
I often want to open a recent file as well as switch buffers, so I bind C-x b to a Consult picker that offers both, including buffers without a file. Consult takes care of removing duplicate recent-file entries. When I only want open buffers, I use C-x C-b. I also track file visits as I switch buffers, so the recent-file list follows my navigation order.
For searching within a buffer, I use Doom’s region-aware search command on C-s, with Consult providing the interface. I bind visual regexp replacement to C-c s r and query replacement to C-c s q. A few other keys I use often: C-c C-g opens Magit, C-? redoes a change, and M-g g or M-g M-g jumps to a line. M-g o lets me pick an outline heading with a live preview.
(defun zz/buffer-or-recentf ()
"Select a buffer or recent file with Consult."
(interactive)
(require 'consult)
(consult-buffer '(consult-source-buffer consult-source-recent-file)))
(defun zz/switch-buffer ()
"Select an open buffer with Consult."
(interactive)
(require 'consult)
(consult-buffer '(consult-source-buffer)))
(map! "C-x b" #'zz/buffer-or-recentf
"C-x C-b" #'zz/switch-buffer)(package! switch-buffer-functions)(use-package! switch-buffer-functions
:after recentf
:preface
(defun my-recentf-track-visited-file (_prev _curr)
(and buffer-file-name
(recentf-add-file buffer-file-name)))
:init
(add-hook 'switch-buffer-functions #'my-recentf-track-visited-file))
(map! "C-s" #'+default/search-buffer)
(map! :after magit "C-c C-g" #'magit-status)(package! pcre2el)
(package! visual-regexp-steroids)(use-package! visual-regexp-steroids
:defer 3
:config
(require 'pcre2el)
(setq vr/engine 'pcre2el)
(map! "C-c s r" #'vr/replace)
(map! "C-c s q" #'vr/query-replace))
(after! undo-fu
(map! :map undo-fu-mode-map "C-?" #'undo-fu-only-redo))
(map! "M-g g" #'avy-goto-line)
(map! "M-g M-g" #'avy-goto-line)
(defun zz/outline ()
"Select a heading using the appropriate Consult command."
(interactive)
(if (derived-mode-p 'org-mode)
(progn (require 'consult-org) (consult-org-heading))
(consult-outline)))
(map! "M-g o" #'zz/outline)Matching parentheses with %#
I got this idea from NavigatingParentheses: after a navigation command, % jumps to the matching delimiter using Smartparens. Otherwise, it types a percent sign as usual. The list of navigation commands below determines when jumping makes sense, so I can use the same key for both.
(after! smartparens
(defun zz/goto-match-paren (arg)
"Go to the matching paren/bracket, otherwise (or if ARG is not
nil) insert %. vi style of % jumping to matching brace."
(interactive "p")
(if (not (memq last-command '(set-mark
cua-set-mark
zz/goto-match-paren
down-list
up-list
end-of-defun
beginning-of-defun
backward-sexp
forward-sexp
backward-up-list
forward-paragraph
backward-paragraph
end-of-buffer
beginning-of-buffer
backward-word
forward-word
mwheel-scroll
backward-word
forward-word
mouse-start-secondary
mouse-yank-secondary
mouse-secondary-save-then-kill
move-end-of-line
move-beginning-of-line
backward-char
forward-char
scroll-up
scroll-down
scroll-left
scroll-right
mouse-set-point
next-buffer
previous-buffer
previous-line
next-line
back-to-indentation
doom/backward-to-bol-or-indent
doom/forward-to-last-non-comment-or-eol
)))
(self-insert-command (or arg 1))
(cond ((looking-at "\\s\(") (sp-forward-sexp) (backward-char 1))
((looking-at "\\s\)") (forward-char 1) (sp-backward-sexp))
(t (self-insert-command (or arg 1))))))
(map! "%" 'zz/goto-match-paren))Org editing and attachments#
Editing defaults#
I keep my Org files in ~/org/. I like new headings to appear right where I’m editing, and I log completed tasks in a LOGBOOK drawer. Org’s special handling of C-a, C-e, and C-k makes those keys work with the heading structure. I also let speed commands work on any of a heading’s leading stars. Electric indentation and completion popups get in my way when I’m writing, so I turn them off in Org; I can still request completion manually in both Org and Markdown.
(setq org-directory "~/org/")
(after! org
(setq org-hide-emphasis-markers t
org-insert-heading-respect-content nil
org-log-done t
org-log-into-drawer t
org-special-ctrl-a/e t
org-special-ctrl-k t))
(after! org
(setq org-use-speed-commands
(lambda ()
(and (looking-at org-outline-regexp)
(looking-back "^\**" (line-beginning-position))))))
(add-hook! org-mode (electric-indent-local-mode -1))
(defun zz/prose-completion-setup ()
"Keep automatic completion quiet in Org and Markdown."
(when (derived-mode-p 'org-mode 'markdown-mode)
(setq-local corfu-auto nil)))
(add-hook 'corfu-mode-hook #'zz/prose-completion-setup)
(add-hook 'org-mode-hook #'zz/prose-completion-setup)
(add-hook 'markdown-mode-hook #'zz/prose-completion-setup)Display#
I prefer variable-pitch text and visual wrapping when I write. Doom’s word-wrap module wraps Org and Markdown paragraphs at the window edge without inserting newlines, so each paragraph can stay on one physical line. I turn off automatic hard wrapping, but can still fill or unfill a paragraph with M-q and A-q. Mixed Pitch uses the fonts I set above. Org Appear, which Doom enables through +pretty, reveals markup when the cursor reaches it, so I can hide the markers without making them awkward to edit.
(defun zz/prose-wrapping-setup ()
"Wrap at the window edge without inserting newlines."
(auto-fill-mode -1)
(+word-wrap-mode 1))
(add-hook 'org-mode-hook #'zz/prose-wrapping-setup t)
(add-hook 'markdown-mode-hook #'zz/prose-wrapping-setup t)
(add-hook 'org-mode-hook #'variable-pitch-mode t)Files, notes, and Roam#
My agenda brings together GTD, work, and personal notes. I use C-c z w, C-c z i, C-c z p, and C-c z d to jump to my work, ideas, projects, and diary files. The helper below makes it easy to add more of these shortcuts. I keep Roam notes in their own directory and store attachments beside each Org document in attachments/.
I leave the automatic Roam backlinks buffer off. If I want it later, Doom’s option for that is +org-roam-auto-backlinks-buffer.
(after! org
(setq org-agenda-files
'("~/gtd" "~/Work/work.org.gpg" "~/org/")))
(defun zz/add-file-keybinding (key file &optional desc)
"Bind KEY to open FILE, using DESC in the key menu."
(map! :desc (or desc file)
key
(lambda () (interactive) (find-file file))))
(zz/add-file-keybinding "C-c z w" "~/Work/work.org.gpg" "work.org")
(zz/add-file-keybinding "C-c z i" "~/org/ideas.org" "ideas.org")
(zz/add-file-keybinding "C-c z p" "~/org/projects.org" "projects.org")
(zz/add-file-keybinding "C-c z d" "~/org/diary.org" "diary.org")
(setq org-roam-directory "~/Dropbox/Personal/org-roam/")
(setq org-attach-id-dir "attachments/")Clipboard images and attachments#
I use Org’s built-in org-attach and yank-media to paste images. My C-y command checks the clipboard: if there’s an image, it asks for a filename and attaches it; otherwise, it calls org-yank. A prefix argument forces normal yanking. I enable attachment ID inheritance so the headings within a post can share one attachment directory.
For image export, I need bare links such as [[attachment:image.png]]. Org 9.8’s image handler adds a filename description, so the advice below strips it while I paste an image. Other attachment links can still have descriptions. This hooks into a private Org function, so I’ll need to check it when I upgrade Org.
(after! org
(setq org-attach-use-inheritance t
org-attach-preferred-new-method 'id
org-attach-auto-tag nil
org-yank-dnd-method 'attach
org-yank-image-file-name-function #'org-yank-image-read-filename
org-image-actual-width nil
org-image-max-width 300))
(after! org
(defun zz/org-yank-image-without-description (handler &rest args)
"Run image HANDLER with bare links suitable for inline image export."
(let ((formatter (symbol-function 'org-link-make-string-for-buffer)))
(cl-letf (((symbol-function 'org-link-make-string-for-buffer)
(lambda (link &optional _description interactive-p)
(funcall formatter link nil interactive-p))))
(apply handler args))))
(advice-add 'org--image-yank-media-handler :around
#'zz/org-yank-image-without-description))
(defun zz/org-smart-yank (&optional arg)
"Yank normally, or use `yank-media' for clipboard images."
(interactive "P")
(let ((targets (and (display-graphic-p)
(gui-get-selection 'CLIPBOARD 'TARGETS))))
(if (and (not arg)
(seq-some
(lambda (type)
(string-match-p
"^image/\\(png\\|jpeg\\|webp\\|gif\\)$"
(format "%s" type)))
(append targets nil)))
(yank-media)
(org-yank arg))))
(after! org
(map! :map org-mode-map
"C-y" #'zz/org-smart-yank))Links between headings#
I use C-c l l h to pick a heading with Vertico and insert a link to it. The choices include outline paths and line numbers, which help when several headings have the same title. I prefer readable CUSTOM_ID values based on the title, so the command creates one if needed and inserts a local #id link. If the heading already has an ID, I reuse it so links to published pages keep working.
(map! :after org :map org-mode-map
"C-c l l h" #'zz/org-insert-heading-link)
(after! org-id
;; Do not create ID if a CUSTOM_ID exists
(setq org-id-link-to-org-use-id 'create-if-interactive-and-no-custom-id))
(defun zz/make-id-for-title (title)
"Return an ID based on TITLE."
(replace-regexp-in-string "[^[:alnum:]]" "-" (downcase title)))
(defun zz/org-custom-id-create ()
"Create and store CUSTOM_ID for current heading."
(let* ((title (or (nth 4 (org-heading-components)) ""))
(new-id (zz/make-id-for-title title)))
(org-entry-put nil "CUSTOM_ID" new-id)
(org-id-add-location new-id (buffer-file-name (buffer-base-buffer)))
new-id))
(defun zz/org-custom-id-get-create (&optional where force)
"Get or create CUSTOM_ID for heading at WHERE.
If FORCE is t, always recreate the property."
(org-with-point-at where
(let ((old-id (org-entry-get nil "CUSTOM_ID")))
;; If CUSTOM_ID exists and FORCE is false, return it
(if (and (not force) old-id (stringp old-id))
old-id
;; otherwise, create it
(zz/org-custom-id-create)))))
(defun zz/org-insert-heading-link ()
"Select a heading and insert its readable CUSTOM_ID link at point."
(interactive)
(require 'org-id)
(let ((origin (copy-marker (point) t)) candidates)
(unwind-protect
(save-restriction
(widen)
(setq candidates
(org-map-entries
(lambda ()
(cons (format "%s [line %d]"
(string-join (org-get-outline-path t t) "/")
(line-number-at-pos))
(point-marker)))))
(unless candidates (user-error "No headings in this buffer"))
(let* ((choice (completing-read "Link to heading: " candidates nil t))
(target (cdr (assoc choice candidates)))
(title (org-with-point-at target
(nth 4 (org-heading-components))))
(id (save-excursion (zz/org-custom-id-get-create target))))
(goto-char origin)
(org-insert-link nil (concat "#" id) title)))
(set-marker origin nil)
(dolist (candidate candidates)
(set-marker (cdr candidate) nil)))))Links from macOS applications#
On macOS, I use C-c g to grab a link from another application with org-mac-grab-link. I turn off its Acrobat and DEVONthink capture helpers, and only load the package and binding on macOS.
(when IS-MAC
(package! org-mac-link))(when IS-MAC
(use-package! org-mac-link
:after org
:config
(setq org-mac-grab-Acrobat-app-p nil) ; Disable grabbing from Adobe Acrobat
(setq org-mac-grab-devonthink-app-p nil) ; Disable grabbinb from DevonThink
(map! :map org-mode-map
"C-c g" #'org-mac-grab-link)))Tasks and capture#
Agenda, holidays, archiving, and clocks#
I include diary entries and Mexican and Swiss holidays in my agenda, with a couple of Zürich holidays added below. Org Super Agenda groups the items by directory. I archive tasks in a date tree in the neighboring archive.org file, unless I specify a different destination with #+archive: in a document. I also save running clocks and clock history between sessions.
(after! org-agenda
(setq org-agenda-include-diary t))(package! mexican-holidays)
(package! swiss-holidays)(use-package! holidays
:after org-agenda
:config
(require 'mexican-holidays)
(require 'swiss-holidays)
(setq swiss-holidays-zh-city-holidays
'((holiday-float 4 1 3 "Sechseläuten")
(holiday-float 9 1 3 "Knabenschiessen")))
(setq calendar-holidays
(append '((holiday-fixed 1 1 "New Year's Day")
(holiday-fixed 2 14 "Valentine's Day")
(holiday-fixed 4 1 "April Fools' Day")
(holiday-fixed 10 31 "Halloween")
(holiday-easter-etc)
(holiday-fixed 12 25 "Christmas")
(solar-equinoxes-solstices))
swiss-holidays
swiss-holidays-labour-day
swiss-holidays-catholic
swiss-holidays-zh-city-holidays
holiday-mexican-holidays)))(package! org-super-agenda)(use-package! org-super-agenda
:after org-agenda
:config
(setq org-super-agenda-groups '((:auto-dir-name t)))
(org-super-agenda-mode 1))
(use-package! org-archive
:after org
:config
(setq org-archive-location "archive.org::datetree/"))
(after! org-clock
(setq org-clock-persist t)
(org-clock-persistence-insinuate))GTD workflow#
I use org-gtd to manage my tasks, with its files in ~/gtd/. For delegated tasks, I show DELEGATED_TO on the next line in the agenda so I can see who’s responsible. My shortcuts are C-c d c for capture, C-c d a for the agenda, C-c d p to process the inbox, C-c d n for next actions, C-c d s for stuck projects, and C-c d f to organize the item I’ve just clarified.
The org-gtd-update-ack setting acknowledges the version 4 upgrade and suppresses its notice. It doesn’t migrate task data; org-gtd’s migration instructions cover that separately for version 3 projects.
(package! org-gtd);; Retain the existing acknowledgment of the v4 upgrade.
(setq org-gtd-update-ack "4.0.0")
(use-package! org-gtd
:after org
:config
(setq org-gtd-directory "~/gtd/"
org-gtd-agenda-property-list '("DELEGATED_TO")
org-gtd-agenda-property-position 'next-line
org-edna-use-inheritance t)
;; Keep dependency processing enabled when reloading this configuration.
(org-edna-mode 1)
:bind
(("C-c d c" . org-gtd-capture)
("C-c d a" . org-agenda-list)
("C-c d p" . org-gtd-process-inbox)
("C-c d n" . org-gtd-show-all-next)
("C-c d s" . org-gtd-reflect-stuck-projects)
("C-c d f" . org-gtd-organize)))Capture templates#
I use the i template to capture an inbox item and l when I also want a link to where I am in Emacs. On macOS, m captures a link to the current Outlook message. I let org-gtd-inbox-path choose the inbox filename. The advice below loads GTD before opening the capture menu, so the templates are ready when I need them.
(after! (org-gtd org-capture)
(add-to-list 'org-capture-templates
'("i" "GTD item"
entry
(file org-gtd-inbox-path)
"* %?\n%U\n\n %i"
:kill-buffer t))
(add-to-list 'org-capture-templates
'("l" "GTD item with link to where you are in emacs now"
entry
(file org-gtd-inbox-path)
"* %?\n%U\n\n %i\n %a"
:kill-buffer t))
(when IS-MAC
(add-to-list 'org-capture-templates
'("m" "GTD item with link to current Outlook mail message"
entry
(file org-gtd-inbox-path)
"* %?\n%U\n\n %i\n %(org-mac-outlook-message-get-links)"
:kill-buffer t))))
(defadvice! +zz/load-org-gtd-before-capture (&rest _)
"Register GTD templates before opening the capture menu."
:before #'org-capture
(require 'org-capture)
(require 'org-gtd))Export and publishing#
Standard export menu#
I hide Org, iCalendar, and ODT from the standard export menu because I rarely need them there. Their export commands still work. The eval forms delay expansion of the structure setters until each backend loads; without that, Doom can try to expand them before ox is ready.
(after! ox-org
(eval '(setf (org-export-backend-menu
(org-export-get-backend 'org))
nil)))
(after! ox-icalendar
(eval '(setf (org-export-backend-menu
(org-export-get-backend 'icalendar))
nil)))
(after! ox-odt
(eval '(setf (org-export-backend-menu
(org-export-get-backend 'odt))
nil)))Transient export menu#
The standard Org export menu becomes a bit unwieldy when many exporter modules are available. I’d like to have those available, but I don’t use them very often, so I defined a new custom export menu using Transient, which includes only my most commonly-used exporters: Leanpub Markua book export, Hugo posts, and Typst/LaTeX export to PDF. It also offers a key to open the standard export menu in case it’s ever needed.
(after! transient
(defun zz/org-export-run (format &optional open)
"Export FORMAT with this menu's options, optionally OPEN the result."
(let* ((args (transient-args 'zz/org-export-menu))
(subtree (and (member "--subtree" args) t))
(visible (and (member "--visible" args) t))
(body (and (member "--body" args) t))
(backend (pcase format
('typst 'ox-typst) ('latex 'ox-latex) ('html 'ox-html)
('markua-book 'ox-leanpub)))
(command (pcase format
('typst #'org-typst-export-to-pdf)
('latex #'org-latex-export-to-pdf)
('html #'org-html-export-to-html)
('markua-book #'org-leanpub-book-export-markua))))
(require backend)
(let ((file (if (eq format 'markua-book)
(funcall command subtree visible)
(funcall command nil subtree visible body))))
(when (and open file) (org-open-file file)))))
(transient-define-prefix zz/org-export-menu ()
"Export this Org document, Hugo post, or Leanpub book."
["Document options"
("-s" "Subtree only" "--subtree")
("-v" "Visible only" "--visible")
("-b" "Body only" "--body")]
[["Documents"
("y" "Typst PDF" (lambda () (interactive) (zz/org-export-run 'typst)))
("Y" "Typst PDF and open" (lambda () (interactive) (zz/org-export-run 'typst t)))
("l" "LaTeX PDF" (lambda () (interactive) (zz/org-export-run 'latex)))
("h" "HTML" (lambda () (interactive) (zz/org-export-run 'html)))]
["Hugo (own scope)"
("H" "Current post" (lambda () (interactive)
(require 'ox-hugo) (org-hugo-export-wim-to-md)))
("A" "All posts" (lambda () (interactive)
(require 'ox-hugo) (org-hugo-export-wim-to-md t)))]
["Leanpub"
("m" "Markua book" (lambda () (interactive) (zz/org-export-run 'markua-book)))]
["Other"
("e" "Standard dispatcher" org-export-dispatch)
("q" "Quit" transient-quit-one)]]))I bind the new menu to C-c C-e (the same key originally used by the standard menu).
(after! org
;; Load the prefix definition before binding it directly.
(require 'transient)
(map! :map org-mode-map "C-c C-e" #'zz/org-export-menu))LaTeX PDF with Tectonic#
I use Tectonic for LaTeX PDF export: C-c C-e l exports a PDF through my custom menu. In the standard dispatcher (C-c C-e e), l p exports a PDF and l o opens it too. Tectonic downloads missing TeX resources and handles reruns for me; its executable needs to be on Emacs’ exec-path. I select xelatex for Org’s engine-specific output because Tectonic uses XeTeX.
(after! ox-latex
(setq org-latex-compiler "xelatex"
org-latex-pdf-process
'("tectonic --keep-logs --outdir %o %f")))Typst PDF#
I use ox-typst for Typst export, with C-c C-e y as the shortcut in my custom menu. My printable cheat sheet has its own landscape, two-column template, fonts, title, and footer, so those settings only apply to that document. The typst executable needs to be on Emacs’ exec-path. I configure tree-sitter editing support with the other language modes below.
(package! ox-typst)(use-package! ox-typst
:after org)Hugo posts#
I write my blog posts in Org and export them with ox-hugo. I install it explicitly and load it after ox. I also ask it to export keyboard markup as code, which works with my site’s styling.
My h capture template, based on the ox-hugo capture example, adds a post under Ideas in my website’s Org source. It sets up a dated page bundle, a readable anchor, an attachment ID, and a default featured image, so I can get straight to writing.
(package! ox-hugo)(use-package! ox-hugo
:after ox
:config
(setq org-hugo-use-code-for-kbd t))I want Hugo to put assets in the same place whether I opened the source file myself or Emacs restored it at startup. To make that consistent, I normalize the export directory through org-export-before-processing-functions. That hook runs in the temporary export buffer, so my source buffer’s directory stays as it was.
I currently set zz/org-hugo-directory-style to abbreviated to get flat asset copying for my site under the home directory. This relies on a path-comparison bug in ox-hugo 0.12.1, so an upstream fix could change it. Setting it to canonical uses ox-hugo’s nested asset layout instead. The workaround doesn’t guarantee flat copying outside the home directory, and the exporter’s separate rules for static files and bundle-named directories still apply.
(defcustom zz/org-hugo-directory-style 'abbreviated
"Directory representation for Hugo export: canonical or abbreviated.
Abbreviated preserves flat assets under HOME with ox-hugo 0.12.1,
but relies on its path-comparison bug, not a supported layout option."
:type '(choice (const canonical) (const abbreviated))
:group 'org-export-hugo)
(defun zz/org-hugo-normalize-export-directory (backend)
"Normalize the temporary export buffer's directory for Hugo BACKEND."
(when (org-export-derived-backend-p backend 'hugo)
(setq default-directory
(if (eq zz/org-hugo-directory-style 'abbreviated)
(abbreviate-file-name (file-truename default-directory))
(file-truename default-directory)))))
(after! ox
(add-hook 'org-export-before-processing-functions
#'zz/org-hugo-normalize-export-directory))(after! org-capture
(defun org-hugo-new-subtree-post-capture-template ()
"Returns `org-capture' template string for new Hugo post.
See `org-capture-templates' for more information."
(require 'ox-hugo)
(let* ((title (read-from-minibuffer "Post Title: ")) ;Prompt to enter the post title
(fname (org-hugo-slug title)))
(mapconcat #'identity
`(
,(concat "* TODO " title)
":PROPERTIES:"
,(concat ":ID: " (org-id-new))
,(concat ":export_hugo_bundle: " (format-time-string "%Y-%m-%d-") fname)
":export_file_name: index"
,(concat ":custom_id: " fname)
":export_hugo_custom_front_matter: :featured_image /images/tram-zurich.jpg"
":END:"
"#+BEGIN_DESCRIPTION\n%?\n#+END_DESCRIPTION" ;Place the cursor here at the end
"\n<write here>")
"\n")))
(add-to-list 'org-capture-templates
'("h" ;`org-capture' binding + h
"Hugo post"
entry
;; It is assumed that below file is present in `org-directory'
;; and that it has an "Ideas" heading. It can even be a
;; symlink pointing to the actual location of all-posts.org!
(file+olp "~/Personal/websites/zzamboni.org/content-org/zzamboni.org" "Ideas")
(function org-hugo-new-subtree-post-capture-template)
:jump-to-captured t)))Configure mermaid blocks as “raw” (reference) so that they are exported as-is instead of using HTML entities.
(after! ox-hugo
(add-to-list 'org-hugo-special-block-type-properties
'("mermaid" . (:raw t))))Curriculum vitae#
Note: I no longer use Org-CV, I keep my CV in JSON Resume, from which I produce all the necessary output formats. See my blog post Porting my CV from Org+LaTeX to JSON Resume for more details! The code below is kept in the Org file for reference but is not being tangled to my config files anymore.
I load the Awesome CV and Modern CV exporters from my local org-cv development checkout. I also override Awesome CV’s image helper to place the image on the right using wrapfigure.
(package! org-cv
:recipe (:local-repo "~/Dropbox/Personal/devel/emacs/org-cv"))(use-package! ox-awesomecv
:after org
:config
(defun org-awesomecv--cventry-right-img-code (file)
(if file
(format "\\begin{wrapfigure}{r}{0.15\\textwidth}
\\raggedleft\\vspace{-10.0mm}
\\includegraphics[width=0.1\\textwidth]{%s}
\\end{wrapfigure}" file) "")))
(use-package! ox-moderncv
:after org)Leanpub books#
I use my local ox-leanpub checkout to export books in Markua format. Its book commands create the manuscript/ directory, Book.txt, and the exported chapters. I wrote about this workflow in Publishing with Emacs, Org-mode and Leanpub.
(package! ox-leanpub
:recipe (:local-repo "~/Dropbox/Personal/devel/emacs/ox-leanpub"))(use-package! ox-leanpub
:after org)Reveal presentations#
I use org-re-reveal, which Doom provides through +present, to make presentations. The headline-number helper below skips noexport headings when building slide anchors. On macOS, I can also export the document and refresh its Brave tab with one command, using osascript. Reveal export itself works on Linux too.
To use the browser refresh helper, I first open the exported presentation with C-c C-e e v b. For automatic refresh on save, I add this file-local hook to the presentation and approve it when Emacs asks:
* COMMENT Local variables :ARCHIVE:noexport:
# Local Variables:
# eval: (add-hook! after-save :append :local (zz/refresh-reveal-prez))
# End:(defun zz/org-current-headline-number ()
"Get the numbering of the innermost headline which contains the
cursor. Returns nil if the cursor is above the first level-1
headline, or at the very end of the file. Does not count
headlines tagged with :noexport:"
(require 'org-num)
(let ((org-num--numbering nil)
(original-point (point)))
(save-mark-and-excursion
(let ((new nil))
(org-map-entries
(lambda ()
(when (org-at-heading-p)
(let* ((level (nth 1 (org-heading-components)))
(numbering (org-num--current-numbering level nil)))
(let* ((current-subtree (save-excursion (org-element-at-point)))
(point-in-subtree
(<= (org-element-property :begin current-subtree)
original-point
(1- (org-element-property :end current-subtree)))))
;; Get numbering to current headline if the cursor is in it.
(when point-in-subtree (push numbering
new))))))
"-noexport")
;; New contains all the trees that contain the cursor (i.e. the
;; innermost and all its parents), so we only return the innermost one.
;; We reverse its order to make it more readable.
(reverse (car new))))))
(defun zz/refresh-reveal-prez ()
;; Export the file
(org-re-reveal-export-to-html)
(let* ((slide-list (zz/org-current-headline-number))
(slide-str (string-join (mapcar #'number-to-string slide-list) "-"))
;; Determine the filename to use
(file (concat (file-name-directory (buffer-file-name))
(org-export-output-file-name ".html" nil)))
;; Final URL including the slide number
(uri (concat "file://" file "#/slide-" slide-str))
;; Get the document title
(title (cadar (org-collect-keywords '("TITLE"))))
;; Command to reload the browser and move to the correct slide
(cmd (concat
"osascript -e \"tell application \\\"Brave\\\" to repeat with W in windows
set i to 0
repeat with T in (tabs in W)
set i to i + 1
if title of T is \\\"" title "\\\" then
reload T
delay 0.1
set URL of T to \\\"" uri "\\\"
set (active tab index of W) to i
end if
end repeat
end repeat\"")))
;; Short sleep seems necessary for the file changes to be noticed
(sleep-for 0.2)
(call-process-shell-command cmd)))Org automation#
Document macro helpers#
I use these helpers in Org macros to format Hammerspoon API links, keyboard sequences, and Lua documentation links. The Lua helper points to version 5.3, which is the version those documents refer to.
(defun zz/org-if-str (str &optional desc)
(when (org-string-nw-p str)
(or (org-string-nw-p desc) str)))
(defun zz/org-macro-hsapi-code (module &optional func desc)
(org-link-make-string
(concat "https://www.hammerspoon.org/docs/"
(concat module (zz/org-if-str func (concat "#" func))))
(or (org-string-nw-p desc)
(format "=%s="
(concat module
(zz/org-if-str func (concat "." func)))))))
(defun zz/org-macro-keys-code-outer (str)
(mapconcat (lambda (s)
(concat "~" s "~"))
(split-string str)
(concat (string ?\u200B) "+" (string ?\u200B))))
(defun zz/org-macro-keys-code-inner (str)
(concat "~" (mapconcat #'identity
(split-string str)
(concat (string ?\u200B) "-" (string ?\u200B)))
"~"))
(defun zz/org-macro-keys-code (str)
(zz/org-macro-keys-code-inner str))
(defun zz/org-macro-luadoc-code (func &optional section desc)
(org-link-make-string
(concat "https://www.lua.org/manual/5.3/manual.html#"
(zz/org-if-str func section))
(zz/org-if-str func desc)))
(defun zz/org-macro-luafun-code (func &optional desc)
(org-link-make-string
(concat "https://www.lua.org/manual/5.3/manual.html#"
(concat "pdf-" func))
(zz/org-if-str (concat "=" func "()=") desc)))Reformatting and importing#
When I want to regenerate an Org document from its parsed structure, I run zz/org-reformat-buffer. It asks for confirmation before rewriting the buffer. I also use org-pandoc-import to bring in other document formats; that needs Pandoc installed.
(defun zz/org-reformat-buffer ()
(interactive)
(when (y-or-n-p "Really format current buffer? ")
(let ((document (org-element-interpret-data (org-element-parse-buffer))))
(erase-buffer)
(insert document)
(goto-char (point-min)))))(package! org-pandoc-import
:recipe (:host github
:repo "tecosaur/org-pandoc-import"
:files ("*.el" "filters" "preprocessors")))(use-package! org-pandoc-import)Structured queries#
I use Org ML to manipulate Org elements and Org QL to query them. The heading helpers below build lists of links based on local tags, either ones I specify or those on the current heading. I can call them from my Org documents.
(package! org-ml)(use-package! org-ml
:after org)(package! org-ql)(use-package! org-ql
:after org)
(defun zz/headings-with-tags (file tags)
(string-join
(org-ql-select file
`(tags-local ,@tags)
:action '(let ((title (org-get-heading 'no-tags 'no-todo)))
(concat "- "
(org-link-make-string
(format "file:%s::*%s" file title)
title))))
"\n"))
(defun zz/headings-with-current-tags (file)
(let ((tags (s-split ":" (cl-sixth (org-heading-components)) t)))
(zz/headings-with-tags file tags)))Programming#
Automatic tangling#
I use Org Auto Tangle to regenerate code when I save an Org file. I enable it in all Org buffers by default, so it works for other literate documents as well as this configuration. For a document I don’t want to tangle automatically, I add #+auto_tangle: nil.
(package! org-auto-tangle)(use-package! org-auto-tangle
:defer t
:hook (org-mode . org-auto-tangle-mode)
:config
(setq org-auto-tangle-default t))Lisp editing#
I use Smartparens strict mode in Lisp buffers, with Paredit-style bindings. M-( wraps the next expression in parentheses, or more expressions if I give it a numeric prefix. To look up the function at point, I use C-h C-f or the localleader g p binding.
(defun zz/sp-enclose-next-sexp (num)
(interactive "p")
(insert-parentheses (or num 1)))
(after! smartparens
(add-hook! (clojure-mode
emacs-lisp-mode
lisp-mode
cider-repl-mode
racket-mode
racket-repl-mode) :append #'smartparens-strict-mode)
(add-hook! smartparens-mode :append #'sp-use-paredit-bindings)
(map! :map (smartparens-mode-map smartparens-strict-mode-map)
"M-(" #'zz/sp-enclose-next-sexp))
(after! prog-mode
(map! :map prog-mode-map "C-h C-f" #'find-function-at-point)
(map! :map prog-mode-map
:localleader
:desc "Find function at point"
"g p" #'find-function-at-point))Additional languages and Babel#
Beyond the languages covered by my Doom modules, I install modes for Elvish, Fish, CFEngine, D2, and Zig, plus package linting. I also add Babel support for Elvish and CFEngine so I can run their code blocks from Org. I ask D2 to put temporary files in the current directory.
(package! elvish-mode)
(package! ob-elvish)
(package! fish-mode)
(package! ob-cfengine3)(use-package! cfengine
:defer t
:commands cfengine3-mode
:mode ("\\.cf\\'" . cfengine3-mode))(package! d2-mode)(use-package! d2-mode)
(after! d2-mode
(setq d2-tmp-dir "."))(package! package-lint)
(package! zig-mode)Typst and Dockerfiles#
I use Typst TS mode for editing Typst files, which needs a compatible tree-sitter grammar. I also add Dockerfile mode for files named Dockerfile and allow string values for its dockerfile-image-name file-local variable. For compilation buffers, I use pipes to avoid terminal escape sequences in the output.
(package! typst-ts-mode :recipe (:host codeberg :repo "meow_king/typst-ts-mode"))
(package! dockerfile-mode)(add-to-list 'auto-mode-alist '("Dockerfile\\'" . dockerfile-mode))
(put 'dockerfile-image-name 'safe-local-variable #'stringp)
(defun plain-pipe-for-process () (setq-local process-connection-type nil))
(add-hook 'compilation-mode-hook 'plain-pipe-for-process)Desktop and development tools#
Magit repository overview#
My Elvish theme already keeps a list of repositories in a JSON file, so I have Magit read that list whenever I run magit-list-repositories. The overview shows each repository’s status, how far it is ahead of or behind upstream, and its path. This needs the JSON file at zz/repolist to exist.
(after! magit
(setq zz/repolist
"~/.elvish/package-data/elvish-themes/chain-summary-repos.json")
(defadvice! +zz/load-magit-repositories ()
:before #'magit-list-repositories
(setq magit-repository-directories
(seq-map (lambda (e) (cons e 0)) (json-read-file zz/repolist))))
(setq magit-repolist-columns
'(("Name" 25 magit-repolist-column-ident nil)
("Status" 7 magit-repolist-column-flag nil)
("B<U" 3 magit-repolist-column-unpulled-from-upstream
((:right-align t)
(:help-echo "Upstream changes not in branch")))
("B>U" 3 magit-repolist-column-unpushed-to-upstream
((:right-align t)
(:help-echo "Local changes not in upstream")))
("Path" 99 magit-repolist-column-path nil))))Encryption#
I let GPG use its graphical pinentry prompt and encrypt files to my email address by default.
(after! epa
(setq epg-pinentry-mode nil)
(setq epa-file-encrypt-to '("diego@zzamboni.org")))Text editing and timing#
I use C-; to edit matching occurrences together with Iedit. It’s handy for text changes, though it doesn’t understand symbols the way a language-aware rename command does. The small zz/measure-time macro lets me time a Lisp expression and get the elapsed seconds.
(package! iedit)(use-package! iedit
:defer
:config
(set-face-background 'iedit-occurrence "Magenta")
:bind
("C-;" . iedit-mode))
(defmacro zz/measure-time (&rest body)
"Measure the time it takes to evaluate BODY."
`(let ((time (current-time)))
,@body
(float-time (time-since time))))Terminal#
Elvish is my shell of choice, so I use it in Vterm too. I look for it on Emacs’ exec-path, with /usr/local/bin/elvish as a fallback.
(setq vterm-shell (or (executable-find "elvish") "/usr/local/bin/elvish"))Filling, annotations, and file formats#
I bind M-q to toggle between filling and unfilling a paragraph, and A-q to unfill it directly. I also install Annotate for external annotations, GIFT mode for quizzes, and Just mode for task files.
(package! unfill)(use-package! unfill
:defer t
:bind
("M-q" . unfill-toggle)
("A-q" . unfill-paragraph))(package! annotate)
(package! gift-mode)
(package! just-mode)Chezmoi and Magit#
I include the Chezmoi Magit extension in the package recipe so I can load it through load-path without hardcoding a Straight checkout path. Opening Magit loads the integration, and I can also call chezmoi-magit-status directly.
(package! chezmoi
:recipe (:files (:defaults "extensions/chezmoi-magit.el")))(use-package! chezmoi
:defer t)
(use-package! chezmoi-magit
:after magit
:commands chezmoi-magit-status)Jira#
I have Org Jira installed for managing issues from Emacs. The URL below is a placeholder; it needs a real Jira server address before I can use it.
(package! org-jira)(make-directory "~/.org-jira" 'ignore-if-exists)
(setq jiralib-url "https://jira.example.com/")Posting to 750words.com#
I use my 750words-client package to submit writing from Emacs. Its commands and Org exporter load when I need them.
(package! 750words
:recipe (:host github
:repo "zzamboni/750words-client"
:files ("*.el")))(use-package! 750words :defer t)
(use-package! ox-750words :defer t)References#
I’ve borrowed ideas from these configurations and tutorials along the way:

