Introduction

Gemini is often described as the “small web”: a space with fewer frills, less overhead, and a friendlier pace than today’s HTTP-dominated internet. For me, it’s also an excuse to play with Emacs and see how far I can bend it into being my all-in-one publishing tool.

What follows is a tour through the little pipeline I’ve cobbled together in a single Emacs package: local/lh-gemini.el. With it, I can:

  • Spin up a brand new post with a single command
  • Keep my index page tidy and up to date
  • Spit out an Atom feed so folks can subscribe
  • All without ever leaving the comfort of Emacs

It’s simple. But that’s very much in the spirit of Gemini so let’s dive in!

Core Variables

First, I tell Emacs a few basics about my capsule: where the files live (using TRAMP to talk to my server), what host name they serve under, and when the site was “born” so we can keep our atom feed stable.

1
2
3
4
5
6
7
8
(defvar +lh/gmi-root "/ssh:remort:/srv/gemini"
  "TRAMP path to root of gemini content directory.")

(defvar +lh/gmi-host "remort.app"
  "Hostname used to build absolute gemini:// links and tag IDs.")

(defvar +lh/gmi-site-inception "2025-09-04"
  "Site inception date for tag: URIs (YYYY-MM-DD). Keep stable forever.")

This way, if I ever move the capsule to a new box, I just tweak these values and everything else falls in line.

Utilities

A handful of helpers keep things running smoothly:

  • Turning titles into safe filenames
  • Grabbing the first paragraph of a post to use as a summary
  • Reading titles straight from Gemtext headers
  • Formatting timestamps into nice, clean ISO8601 strings
1
2
3
4
5
6
7
8
9
(defun +lh/gmi--slugify (s)
  (require 'subr-x)
  (require 'ucs-normalize)
  (let* ((s (ucs-normalize-NFD-string s))
         (s (replace-regexp-in-string "[\u0300-\u036f]" "" s))
         (s (downcase s))
         (s (replace-regexp-in-string "[^a-z0-9]+" "-" s))
         (s (replace-regexp-in-string "^-+\\|-+$" "" s)))
    s))

So “Héllo, World!” becomes hello-world.gmi. Handy, tidy, and predictable.

Building the Atom Feed

One of my goals was to let people follow my capsule without needing to check it manually. Atom is the perfect format for that: widely supported, and easy to generate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
(defun +lh/gmi-build-atom-feed ()
  "Build /posts/atom.xml from posts/, newest-first. TRAMP-safe."
  (interactive)
  (require 'xml)
  (message "Rebuilding gemini atom feed...")
  (let* ((posts   (+lh/gmi--post-files-sorted (expand-file-name "posts" +lh/gmi-root)))
         (feed    (expand-file-name "posts/atom.xml" +lh/gmi-root))
         (self    (format "gemini://%s/posts/atom.xml" +lh/gmi-host))
         (alt     (format "gemini://%s/posts/" +lh/gmi-host))
         (updated (if posts
                      (+lh/gmi--iso8601-utc
                       (file-attribute-modification-time
                        (file-attributes (car posts))))
                    (+lh/gmi--iso8601-utc (current-time))))
         (feed-id (format "tag:%s,%s:/posts/" +lh/gmi-host +lh/gmi-site-inception)))
    ;; ... feed construction omitted for brevity ...
    ))

It loops through my posts/ directory, pulls out the titles and summaries, and emits valid Atom XML. With that, anyone can subscribe in their favorite reader.

Rebuilding the Index

Gemini capsules all have an index.gmi, but I like to keep a separate Gemlog index (posts/index.gmi) that lists posts in reverse chronological order. Here’s the function that takes care of it:

1
2
3
4
5
6
7
8
(defun +lh/gmi-rebuild-posts-index ()
  "Rebuild /posts/index.gmi in Gemlog format (newest-first). TRAMP-safe."
  (interactive)
  (let* ((dir (expand-file-name "posts" +lh/gmi-root))
         (idx (expand-file-name "posts/index.gmi" +lh/gmi-root))
         (files (+lh/gmi--post-files-sorted dir)))
    ;; ... insert headers, loop over files, write index ...
    ))

Whenever I rebuild the index, I also call +lh/gmi-build-atom-feed so the feed is always fresh.

Creating New Posts

The part I touch most often is the “new post” command. It does just enough setup to get me started, then gets out of the way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
(defun +lh/gmi-new-post ()
  "Create /posts/YYYY-MM-DD-slug.gmi then rebuild the posts index."
  (interactive)
  (let* ((title (read-string "Post title: "))
         (date  (format-time-string "%Y-%m-%d"))
         (slug  (+lh/gmi--slugify title))
         (dir   (expand-file-name "posts" +lh/gmi-root))
         (file  (expand-file-name (format "%s-%s.gmi" date slug) dir)))
    (make-directory dir t)
    (find-file file)
    (when (= (buffer-size) 0)
      (insert (format "# %s\n\n%s\n\n=> /posts Back to posts\n=> / Back to index\n" title date))
      (save-buffer))
    (message "New post: /posts/%s-%s.gmi" date slug)))

So if I type “My First Capsule Post,” it becomes 2025-09-22-my-first-capsule-post.gmi, opens remotely via TRAMP, and comes pre-seeded with a header and navigation links. Then I just write.

Keybindings

Because I’m on Doom Emacs, I wired everything up under the leader key:

1
2
3
4
5
(map!
 :leader
 :desc "New Gemini post" "n g p" #'+lh/gmi-new-post
 :desc "Rebuild posts index" "n g r" #'+lh/gmi-rebuild-posts-index
 :desc "Rebuild Gemini Atom Feed" "n g a" #'+lh/gmi-build-atom-feed)

That means SPC n g p makes a new post, SPC n g r refreshes the index, and SPC n g a regenerates the feed if I want it on demand.

Wrapping up

This isn’t some grand static site generator—and that’s exactly the charm. Gemini thrives on small, hand-rolled tools, and with just a few dozen lines of Lisp I’ve got a pipeline that feels tailor-made for me.

If you’re an Emacs user dabbling in Gemini, consider rolling your own. The nice thing is that nothing here is special: it’s just Emacs Lisp, ready for you to tweak, remix, and shape into whatever fits your corner of the small web.