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:

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!.

Doom Emacs (web)
Back to posts
Back to index