Posting from Emacs
2025-09-05
So I made this capsule and decided I needed a good way to make posts. I'm sure there's - y'know - actual real solutions to this. But this is my solution. I had some requirements:
- I wanted it to give me some kind of basic post template so I'm not doing boiler plate.
- Low Friction! It should update the posts/index.gmi and atom.xml files without me having to think about it.
- Not require me to directly ssh into the server and edit on there.
So given those things I figured I'll just turn to the editor I'm already using and leverage emacs. Now emacs has a whole ox-gemtext exporter and stuff but I didn't want to use it. I want to get the full gemini experience and just write in gemtext directly. So a bunch of elisp later and I came up with this - probably terrible - solution:
;;; local/lh-gemini.el -*- lexical-binding: t; -*-
;; A sort of rough and ready content publishing pipeline for my gemini capsule
;; Vars
(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.")
;; Functions
(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))
(defun +lh/gmi--first-paragraph (file)
"Extract a short plain-text summary from FILE (first paragraph)."
(with-temp-buffer
(insert-file-contents-literally file nil 0 16384)
(goto-char (point-min))
;; Skip headers & blank lines
(while (and (not (eobp))
(or (looking-at-p "^[[:space:]]*$")
(looking-at-p "^#")
(looking-at-p "^```") ; skip code fences quickly
(looking-at-p "^=>"))) ; skip link lines
(forward-line 1))
(let ((start (point)))
;; Paragraph until blank line
(while (and (not (eobp))
(not (looking-at-p "^[[:space:]]*$")))
(forward-line 1))
(let* ((txt (buffer-substring-no-properties start (point)))
;; Smash newlines/whitespace
(txt (replace-regexp-in-string "[ \t\n\r]+" " " txt))
(txt (string-trim txt)))
;; Keep it tidy; Atom readers don’t need the whole thing
(when (> (length txt) 300)
(setq txt (concat (substring txt 0 300) "…")))
txt))))
(defun +lh/gmi--post-files ()
"Return newest-first list of post files under posts/ with YYYY-MM-DD-*.gmi."
(let* ((dir (expand-file-name "posts" +lh/gmi-root))
(rx "^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-.*\\.gmi$")
(files (directory-files dir t rx)))
;; Newest-first by filename (date prefix makes lexicographic work)
(sort files (lambda (a b)
(string> (file-name-nondirectory a)
(file-name-nondirectory b))))))
(defun +lh/gmi--iso8601-utc (time)
"Format TIME (Emacs time) as RFC3339 UTC."
(format-time-string "%Y-%m-%dT%H:%M:%SZ" time t))
(defun +lh/gmi--read-title (file)
"Read the first level-1 header as the title from FILE, or fall back to basename."
(with-temp-buffer
;; Read only the first couple KB; plenty for the heading.
(insert-file-contents-literally file nil 0 2048)
(goto-char (point-min))
(if (re-search-forward "^#\\s-*\\(.+\\)$" nil t)
(string-trim (match-string 1))
(file-name-base file))))
(defun +lh/gmi-build-atom-feed ()
"Build /posts/atom.xml from posts/, newest-first. TRAMP-safe."
(interactive)
(require 'xml)
(let* ((posts (+lh/gmi--post-files))
(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))
;; Feed updated = newest post mtime, or now if none
(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)))
(with-temp-buffer
(insert "\n")
(insert "\n")
(insert " " (xml-escape-string (format "%s — Posts" +lh/gmi-host)) " \n")
(insert " " (xml-escape-string feed-id) " \n")
(insert " " updated " \n")
(insert " \n")
(insert " \n")
(insert " Remort \n")
(insert " A small, lazy capsule \n")
(insert " Emacs/" emacs-version " \n")
;; Entries
(dolist (f posts)
(let* ((bn (file-name-nondirectory f))
(date (substring bn 0 10))
(title (+lh/gmi--read-title f))
(sum (+lh/gmi--first-paragraph f))
(href (format "gemini://%s/posts/%s" +lh/gmi-host bn))
(mtime (file-attribute-modification-time (file-attributes f)))
(upd (+lh/gmi--iso8601-utc mtime))
(id (format "tag:%s,%s:/posts/%s" +lh/gmi-host date bn)))
(insert " \n")
(insert " " (xml-escape-string title) " \n")
(insert " " (xml-escape-string id) " \n")
(insert " " upd " \n")
(insert " \n")
(when (and sum (> (length sum) 0))
(insert " "
(xml-escape-string sum)
" \n"))
(insert " \n")))
(insert " \n")
(save-excursion
(goto-char (point-min))
(condition-case err
(xml-parse-region (point-min) (point-max))
(error
(user-error "Atom build failed: %s" (error-message-string err)))))
(write-region (point-min) (point-max) feed nil 'silent))
(message "Atom feed updated: %s" feed)))
(defun +lh/gmi-rebuild-posts-index ()
"Rebuild /posts/index.gmi from files in /posts/, newest-first. TRAMP-safe."
(interactive)
(let* ((dir (expand-file-name "posts" +lh/gmi-root))
(idx (expand-file-name "posts/index.gmi" +lh/gmi-root))
;; Match YYYY-MM-DD-*.gmi
(files (directory-files dir t "^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-.*\\.gmi$")))
;; Sort newest-first by filename (date prefix makes lexicographic sort work).
(setq files (sort files (lambda (a b)
(string> (file-name-nondirectory a)
(file-name-nondirectory b)))))
(with-temp-buffer
(insert "# Posts\n\n")
(dolist (f files)
(let* ((bn (file-name-nondirectory f))
(date (substring bn 0 10))
(url (concat "/posts/" bn))
(title (+lh/gmi--read-title f)))
(insert (format "=> %s %s — %s\n" url title date))))
(insert "\n=> / Back to index\n")
(write-region (point-min) (point-max) idx nil 'silent)))
(+lh/gmi-build-atom-feed))
(defun +lh/gmi-new-post ()
"Create /posts/YYYY-MM-DD-slug.gmi then rebuild the posts index (newest-first)."
(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) ; TRAMP-safe
(find-file file) ; open the new post buffer remotely
(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))
(+lh/gmi-rebuild-posts-index)
(message "New post: /posts/%s-%s.gmi" date slug)))
;; Keybindings
(map!
:leader
:desc "New Gemini post"
"n g p" #'+lh/gmi-new-post)
(map!
:leader
:desc "Rebuild posts index"
"n g r" #'+lh/gmi-rebuild-posts-index)
(map!
:leader
:desc "Rebuild Gemini Atom Feed"
"n g a" #'+lh/gmi-build-atom-feed)
So now my flow is basically calling +lh/gmi-new-post, it asks me for a title and then all that elisp runs and eventually I'm placed into a gemtext-mode buffer with a little post skeleton. It will slugify whatever title my fevered brain names the post and date it for me. It also updates the feed files for me so I don't have to think about it. Neat!
Right now, it updates the posts/index.gmi and atom.xml files at the same time I create the post. I should PROBABLY make that a separate step but I decided I don't really mind if people happen to come across a 'work in progress' post. And because I'm doing this all over TRAMP I don't have to deal with direct ssh into the server so a win there too.
The small web can also be the lazy web!
Feel free to take whatever you need here if this interests you. I use Doom Emacs so if you don't you'll have to do the keybindings however you normally would do that vs using map!.