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
You create a derived mode from
tabulated-list-mode
This defines the column headers in
tabulated-list-format
, and some other stuffYou 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 dataCalles
(tabulated-list-print t)
which displays the data
You create a mode map that lets you add functions, the selected
key
is returned bytabulated-list-get-id
.
One tricky thing to figure out is how to create the data. It looks like
(list
(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)
nil))))
;; 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)))))
(blog-mode-refresh-data)
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)
(tabulated-list-init-header))
(defun blog-list ()
(interactive)
(pop-to-buffer "*Blog Mode*" nil)
(blog-mode)
(blog-mode-refresh-data)
(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.
? | Help |
o | Open the selected file |
r | Refresh lists |
d | Only show drafts |
p | Only show published posts |
a | Show all posts |
c | Create a new post |
s | Start 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 ()
(interactive)
(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 ()
(interactive)
(progn
(blog-mode-refresh-data)
(setq tabulated-list-entries (-non-nil blog-mode-entries))
(tabulated-list-print t)))
(defun blog-mode-all ()
(interactive)
(progn
(setq tabulated-list-entries (-non-nil blog-mode-entries))
(tabulated-list-print t)))
(defun blog-mode-drafts ()
(interactive)
(progn
(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 ()
(interactive)
(progn
(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"
(interactive)
(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"
(interactive)
(let* (
(default-directory "/home/wschenk/willschenk.com")
(height (/ (frame-total-lines) 3))
(name "*shell hugo process"))
(delete-other-windows)
(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)
Conclusion
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.