Better Emacs Recipes With Yasnippet and Org-mode

While I’m pretty happy with my recipe system, I recently learned about org-babel, which is now integreated into the core of org-mode. It turns out this does almost exactly what I want; it’s designed to allow embedding and running code snippets inside an org document in multiple languages. With just a few tweaks, I adapted my previous approach to use org-babel.

The major changes are:

  • Using org-babel source blocks instead of elisp blocks
  • Tweaking recipe-do to fire the org-babel source blocks
  • Substituting local variables at the end of the org file with named blocks at the beginning of the file

Using org-babel source blocks

Org-babel introduces “source blocks,” which are blocks of code in various languages that can be executed. They emit their results in a variety of ways, including to a “result block” below the source block. This is more convenient for tracking the results of running a block.

* Edit blog post
  #+begin_src emacs-lisp :var post-year=post-year[0,0] post-name=post-name[0,0]
  (find-file (format "content/posts/%s/%s/index.md" post-year post-name))
  #+end_src

Variables can be passed in with :var, which reference a named block elsewhere in the file. To deal with newlines on the end of strings, I’ve encoded them as 1-by-1 tables and then accessed the single table element, hence the [0,0] suffixes on the variable bindings. Not ideal, but I haven’t found a shorter solution for stripping newlines.

tweaking recipe-do

The changes to recipe-do aren’t huge; we replace finding the end of the sexp and evaluating it with just evaluating the codeblock.

(defun babel-recipe-do ()
  "Execute the #begin_src block under the current org header.

On completion, the header will be updated to DONE."
  (interactive)
  (save-excursion
    (cl-labels ((is-folded () (and (or (org-at-heading-p) (org-at-item-p)) (invisible-p (point-at-eol)))))
      (let ((was-folded (is-folded)))
	;; exec
	(beginning-of-line)
	(if was-folded (org-cycle))
	(save-excursion
	  (forward-line)
	  (org-ctrl-c-ctrl-c))
	;; successful exec, so cleanup the org entry.
	(org-todo 'done)
	(recipe--update-completion-timestamp)
	(if (and was-folded (not (is-folded))) (org-cycle))))))

org-ctrl-c-ctrl-c is a fancy little function that “does the right thing” at the cursor in org-mode. It’s a lot easier than trying to recapitulate what org-mode does specifically on source blocks.

named blocks as variables

One downside to using local variables was that we had to worry about safety of local buffers. Instead, we can use named blocks as variable inputs to the source blocks.

* DONE [preamble]
#+STARTUP: fold
#+name: post-name
| better-emacs-recipes-yasnippet-org-mode |
#+name: post-year
| 2024 |
#+property: header-args :dir /home/mtomczak/blog/

The variables are enclosed in | org tables | so that they don’t catch a newline on the end of the string. It’s a little frustrating that there’s no straightfoward way to drop the newline from a plaintext block string, but I’ve seen worse issues.

One exception to using #+name blocks is setting the current directory for the blocks with :dir. The way :dir is handled differs from how :var is handled; it doesn’t let you pass in a named block easily, so instead we set it up as a header argument that is added to every code block. We just have to put the cursor on that line once and do C-c C-c when the file is initially created.

The new yasnippet

With all of these changes in place, the updated yasnippet template is as follows:

# -*- mode: snippet -*-
# key: recipe-new-blogpost
# name: New blog post recipe
# --
* DONE [preamble]
#+STARTUP: fold
#+name: post-name
| $1 |
#+name: post-year
| `(nth 5 (decode-time))` |
#+property: header-args :dir `default-directory`

$0* Init
  #+begin_src emacs-lisp
    (org-cycle-global)
  #+end_src
* Create blog post
  #+begin_src emacs-lisp
  (mthugo-create-post "${1:hypthenated-post-name}")
  #+end_src
* Edit blog post
  #+begin_src emacs-lisp :var post-year=post-year[0,0] post-name=post-name[0,0]
  (find-file (format "content/posts/%s/%s/index.md" post-year post-name))
  #+end_src
* Preview blog post
  #+begin_src emacs-lisp
  (compile "hugo serve -D")
  #+end_src
* Set draft to false on blog post
  #+begin_src emacs-lisp
  (message "For now, do this by hand.")
  #+end_src
* Build blog post
  #+begin_src shell
    hugo
  #+end_src
* Commit post
  #+begin_src emacs-lisp :var post-year=post-year[0,0] post-name=post-name[0,0]
  (let ((commit-msg (read-from-minibuffer "Commit message: "))
        (new-post-path (format "content/posts/%s/%s/" post-year post-name)))
  (compile (format "git add --update && git add \\\"%s*\\\" public/* && git commit -m \\\"%s\\\"" new-post-path commit-msg)))

  #+end_src
* Push post
  #+begin_src shell
  git push origin main
  #+end_src
* Publish entry to Mastodon
  #+begin_src emacs-lisp
  (message "For now, do this by hand.")
  #+end_src
* Update comments id
  #+begin_src emacs-lisp :var post-year=post-year[0,0] post-name=post-name[0,0]
  (let ((id-string (read-from-minibuffer "Comments ID string: ")))
   (find-file (format "content/posts/%s/%s/index.md" post-year post-name))
   (goto-char (point-min))
   (search-forward "---")
   (search-forward "---")
   (beginning-of-line)
   (insert "commentsId: " id-string "\n"))
   #+end_src
* Rebuild with comments update
  #+begin_src shell
  hugo
  #+end_src
* Commit post with comments ID
  #+begin_src shell :var post_name=post-name[0,0]
  git add --update && git add public/* && git commit -m "Added comments for ${post_name}"
  #+end_src
* Push post
  #+begin_src shell
    git push origin main
  #+end_src

Final thoughts

There’s some tradeoffs here. While it’s more flexible, it’s certainly more verbose. I’m not a huge fan of the variable syntax in org-babel (although it does allow for variables to be inserted in a nearly language-agnostic way; the variable names do need to match syntax for the embedded language), but I like the ability to fit the language to the task.

Comments