Emacs Blog Writing and Navigation Mode

emacs and hugo sitting in a tree

Published March 15, 2021 #emacs, #hugo, #elisp, #tabulated-list-mode

This blog is basically my labnotes where I explore different parts of technology. Almost all of my coding related activity starts off in this repo, while I explore different things to see how they work. I have a lot of things in drafts, and I wanted to learn how to build a simple emacs interface to let me navigate around my file system.

I couldn't find any good documentation on how to do anything with tabulated-list-mode so I spend the evening poking around and seeing how it works. Here you go.

The final file is blog.el

My basic directory structure

I have my repo checked out at ~/willschenk.com, and I put all my work in content/articles and then the year. So this file is called /home/wschenk/willschenk.com/content/articles/2021/emacs_blogging_mode/index.org

Sometimes the org file is at the top level directory, and in the past I wrote in md files so I want to make sure that they come through as well.

  ;; set the directory
  (setq blog-mode-base-dir "/home/wschenk/willschenk.com/content/articles")
  (require 'transient)

How tabulated-list-mode works

The basic idea is that

  1. You create a derived mode from tabulated-list-mode

  2. This defines the column headers in tabulated-list-format, and some other stuff

  3. You create a function that

    • Creates a new buffer

    • Switches to your derived mode

    • Sets tabluated-list-entries, which is a list of lists, the first element being the key and the following elements are the data

    • Calles (tabulated-list-print t) which displays the data

  4. You create a mode map that lets you add functions, the selected key is returned by tabulated-list-get-id.

One tricky thing to figure out is how to create the data. It looks like

    (list key1 [col1 col2 col3])
    (list key2 [col1 col2 col3]))

Which you can create using (list key1 (vector col1 col2 col3)) if you want to actually use the values that col1 points to rather than the symbol col1 itself. Yay lisp!

Let's get started.

Looking at the front matter

This function takes a file, and passes it through awk to parse the front matter. We will basically call this 4 times for each file to pull out the title, date, draft, and tags.

  (defun blog-mode-file-peek (pattern file)
    (let ((result (car (process-lines "awk" "-F: " (concat pattern " {print $2}") file))))
      (if result
          (replace-regexp-in-string "\"" "" result)

Also, I'm removing any quotes around the results.

Parse a .org file

This takes a file, and pulls out the attributes. I'm assuming that the first ones it find is actually the top matter, we ignore all other matches other than the first.

  (defun blog-mode-parse-org (file)
    (let ((title (blog-mode-file-peek "/\\+title/" file))
          (date (blog-mode-file-peek "/\\+date/" file))
          (draft (blog-mode-file-peek "/\\+draft/" file))
          (tags (blog-mode-file-peek "/\\+tags/" file)))
      (list file (vector title draft date tags))))
  ;; parse an org-file test
  (setq org-test "/home/wschenk/willschenk.com/content/articles/2021/setting_up_emacs_for_typescript_development.org")

  (blog-mode-parse-org org-test)

Parsing an md file

Depending upon what sort of front matter you use, you may need to adjust the regex. All my old markdown files are using yaml and not toml, so your mileage may vary.

  (defun blog-mode-parse-md (file)
    (let ((title (blog-mode-file-peek "/^title/" file))
          (date (blog-mode-file-peek "/^date/" file))
          (draft (blog-mode-file-peek "/^draft/" file))
          (tags (blog-mode-file-peek "/^tags/" file)))
      (list file (vector title draft date tags))))
  ;; parse a md file test
  (setq md-test "/home/wschenk/willschenk.com/content/articles/2020/styling_tables_with_hugo.md")

  (blog-mode-parse-md md-test)

Figure out if its a directory or not

For short posts that don't have any tangling or other sub objects, my org files live in the year directory. For others, it's either going to be index.md or index.org so if we get a directory lets see which one is in there.

  (defun blog-mode-parse-directory (directory)
    (let ((md (concat directory "/index.md"))
          (org (concat directory "/index.org")))
      (if (file-exists-p md)
        (blog-mode-parse-md md)
        (if (file-exists-p org)
          (blog-mode-parse-org org)
  ;; What can we figure out from a directory test
  (setq dir-test "/home/wschenk/willschenk.com/content/articles/2021/gist_in_emacs")

  (blog-mode-parse-directory dir-test)

Figure out which parser to delegate to

Given a file name or a directory, figure out which parse method knows how to make sense of it.

  (defun blog-mode-parse (file)
    (if (file-directory-p file)
        (blog-mode-parse-directory file)
      (let ((ex (file-name-extension file)))
        (if (string= ex "md")
            (blog-mode-parse-md file)
          (if (string= ex "org")
              (blog-mode-parse-org file)
            (message (concat "Unknown extension " ex)))))))
  ;; another test
  (blog-mode-parse org-test)

Scan through all of the files and then parse them

I'm again shelling out to the find command with -maxdepth of 2 to give me a list of the files and/or directories that contain blog posts. For each of the files, I'm parsing them to get the data in tab form that the mode knows how to deal with.

dolist was fun to figure out.

  (defun blog-mode-refresh-data ()
    (setq blog-mode-entries nil)
    (dolist (file (process-lines "find" blog-mode-base-dir  "-maxdepth" "2" "-print"))
      (let ((entry (blog-mode-parse file)))
        (if entry
            (push (blog-mode-parse file) blog-mode-entries)))))

Set up the mode itself

We create a derived mode called blog-mode from tabulated-list-mode. In it we set the columns, padding, sort order (on date) and explicitely tell it to use our mode map, blog-mode-map defined below. It's unclear why it doesn't pick it up automatically, but I needed to call it out specifically.

We also create a blog-list function which is our entry point. This creates and opens a new buffer, switches it to blog-mode, loads in our data, and then tells it to display. tabulated-list-entries is local to the buffer, by the by, so you can have multiple modes using the same variable.

  (define-derived-mode blog-mode tabulated-list-mode "blog-mode" "Major mode Blog Mode, to edit hugo blogs"
    (setq tabulated-list-format [("Title" 60 t)
                                 ("Draft" 5 nil)
                                 ("Date"  11 t)
                                 ("Tags" 0 nil)])
    (setq tabulated-list-padding 2)
    (setq tabulated-list-sort-key (cons "Date" t))
    (use-local-map blog-mode-map)

  (defun blog-list ()
    (pop-to-buffer "*Blog Mode*" nil)
    (setq tabulated-list-entries (-non-nil blog-mode-entries))
    (tabulated-list-print t))

Create the mode map

Here I'm defining some functions that are specific to our mode.

oOpen the selected file
rRefresh lists
dOnly show drafts
pOnly show published posts
aShow all posts
cCreate a new post
sStart the hugo process

For fun I also created a transient popup which shows all of this.

  (defvar blog-mode-map nil "keymap for blog-mode")

  (setq blog-mode-map (make-sparse-keymap))

  (define-key blog-mode-map (kbd "?") 'blog-mode-help)
  (define-key blog-mode-map (kbd "o") 'blog-mode-open)
  (define-key blog-mode-map (kbd "<return>") 'blog-mode-open)
  (define-key blog-mode-map (kbd "d") 'blog-mode-drafts)
  (define-key blog-mode-map (kbd "a") 'blog-mode-all)
  (define-key blog-mode-map (kbd "p") 'blog-mode-published)
  (define-key blog-mode-map (kbd "r") 'blog-mode-refresh-all)
  (define-key blog-mode-map (kbd "c") 'blog-mode-make-draft)
  (define-key blog-mode-map (kbd "s") 'blog-mode-start-hugo)
  (define-key blog-mode-map (kbd "RET") 'blog-mode-open)

  (transient-define-prefix blog-mode-help ()
    "Help transient for blog mode."
    ["Blog mode help"
     ("o" "Open" blog-mode-open)
     ("d" "Drafts" blog-mode-drafts)
     ("a" "All" blog-mode-all)
     ("p" "Published" blog-mode-published)
     ("r" "Refresh" blog-mode-refresh-all)
     ("c" "Create post" blog-mode-make-draft)
     ("s" "Start hugo" blog-mode-start-hugo)

Actions: open

I set the key to be the filename, so (find-file (tabulated-list-get-id)) opens the file.

  (defun blog-mode-open ()
    (find-file (tabulated-list-get-id)))

Actions: All/Published/Drafts

These functions filter the blog-mode-entries variable to filter what is displayed. I'm not sure how I feel about calling tabulated-list-print each time but it seems to work.

  (defun blog-mode-refresh-all ()
      (setq tabulated-list-entries (-non-nil blog-mode-entries))
      (tabulated-list-print t)))

  (defun blog-mode-all () 
      (setq tabulated-list-entries (-non-nil blog-mode-entries))
      (tabulated-list-print t)))

  (defun blog-mode-drafts () 
      (setq tabulated-list-entries 
            (-filter (lambda (x)
                       (string= "true"
                                (aref (car (cdr x)) 1))) (-non-nil blog-mode-entries)))
      (tabulated-list-print t)))

  (defun blog-mode-published () 
      (setq tabulated-list-entries 
            (-filter (lambda (x)
                       (string= ""
                                (aref (car (cdr x)) 1))) blog-mode-entries)))
      (tabulated-list-print t))

Actions: create a new post

I like my urls to be the same as the title, so the first function here normalizes the title to fit in the filesystem. I've forgotten where I copied this code from, by thank you internet.

I have two types of posts. "mini" which just means its a standalone file, and a full post, which is in a directory. I also turn on automatic org-babel-tangle on save, which I set as a local org variable.

  (defun string-title-to-filename (str)
    "FooBar => foo_bar"
    (let ((case-fold-search nil))
      (setq str (replace-regexp-in-string "\\([a-z0-9]\\)\\([A-Z]\\)" "\\1_\\2" str))
      (setq str (replace-regexp-in-string "\\([A-Z]+\\)\\([A-Z][a-z]\\)" "\\1_\\2" str))
      (setq str (replace-regexp-in-string "-" "_" str)) ; FOO-BAR => FOO_BAR
      (setq str (replace-regexp-in-string "_+" "_" str))
      (setq str (replace-regexp-in-string " " "_" str))
      (downcase str)))

  (defun blog-mode-make-draft ()
    "Little function to create a org file inside of the blog"
    (let* (
           (mini (yes-or-no-p "Mini post? "))
           (title (read-from-minibuffer "Title: "))
           (year (format-time-string "%Y"))
           (filename (string-title-to-filename title))
           (rootpath (concat blog-mode-base-dir "/" year "/" filename))
           (path (if mini (concat rootpath ".org") (concat rootpath "/index.org")))
      (set-buffer (find-file path))
      (insert "#+title: " title "\n")
      (insert "#+date: " (format-time-string "%Y-%m-%d") "\n")
      (insert "#+draft: true\n")
      (unless mini
        (insert "\n* References\n# Local Variables:\n# eval: (add-hook 'after-save-hook (lambda ()(org-babel-tangle)) nil t)\n# End:\n"))

Action: Start hugo

This is probably too particular for my machine, since I run hugo inside of a docker container so I need to start it with a script, but this function starts hugo if it isn't running, then waits 5 seconds to call xdg-open to bring it up in the browser.

  (defun blog-mode-start-hugo ()
    "Starts up a hugo watch process"
    (let* (
           (default-directory "/home/wschenk/willschenk.com")
           (height (/ (frame-total-lines) 3))
           (name "*shell hugo process"))
      (split-window-vertically (- height))
      (other-window 1)
      (switch-to-buffer name)
      (unless (get-buffer-process name)
        (async-shell-command "cd /home/wschenk/willschenk.com;./dev.sh" name))
      (async-shell-command "sleep 5;xdg-open http://localhost:1313" (get-buffer "*hugo web opener*"))))

Plug it in

(global-set-key (kbd "C-c d") 'blog-list)


I couldn't find any good tutorials on how to write an emacs mode to interact with my system, so I thought I should write one. I think there's probably something on YouTube but it didn't show up in any search algorithms so hopefully this is helpful.


  1. https://pagefault.se/post/lets-build-docker-mode-part-1/

  2. https://www.gnu.org/software/emacs/manual/html_node/elisp/File-Name-Components.html#File-Name-Components

  3. http://lgmoneda.github.io/2017/03/15/elisp-summary.html#loops

  4. http://ergoemacs.org/emacs/elisp_vector.html

  5. https://stackoverflow.com/questions/2234860/lisp-filter-out-results-from-list-not-matching-predicate

  6. https://vallyscode.github.io/posts/tabulated-list-mode/

Read next

Next Post: CLOCViz

See also

Installing emacs on buster

so many ways to get software

I've already written about installing emacs-snapshot on debian buster, here are two additional ways. The first is to use flatpak, and the other is to build from source. Flatpack sudo apt-get install flatpak sudo apt install gnome-software-plugin-flatpak sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo Then: sudo flatpak install flathub org.gnu.emacs And running it: flatpak run org.gnu.emacs You can then pin it in your dock and you are good to go.

Read more

Getting Websters

More glorious language

A few years ago I read a great post about the value of a good dictionary, which I installed on my laptop and then promptly forgot about. The post is very moving, and I recommend checking it out. It was recently mentioned on the planet emacs blog circuit and so I thought I'd document how to get it up and running on my laptop. Also I don't understand what the appeal of John McPhee's writing, I've tried a few of his books but found them all so plodding.

Read more

Upgrading emacs on debian

fixing crashes

Been playing with elfeed on Emacs 26.1 on buster and it keeps crashing. I think for font related reasons. So lets follow the Emacs Wiki Instructions to upgrade to emacs-snapshots and see if that helps. Add the snap shot repository. Make sure you have the tools installed so that apt can do it's internet thing. sudo apt-get install software-properties-common sudo apt-get update Add the signing key: wget -q http://emacs.

Read more