Writing and Deploying My Blog From Emacs

The blog you’re reading is published entirely from Emacs. I press a keybinding, org files get converted to HTML, indexes get generated, and everything gets uploaded to my server via FTP. No Jekyll, no Hugo, no Node.js. Just Emacs and a 250-line Elisp file called blog.el.

Why Not Just Use a Static Site Generator?

I tried a few things before landing here. org-static-blog is the obvious first stop and it works, but it felt too limiting. I wanted full control over the HTML structure, my own CSS, and the ability to bolt on custom behavior without fighting the tool. Hugo and Jekyll felt like overkill for a personal notes site. And if I’m already writing everything in org-mode and living in Emacs, why route it through an external tool at all?

org-publish is built into Emacs and does one thing: take org files and produce HTML. The trick is configuring it and writing the glue around it. That’s what blog.el is.

The Basic Structure

The file has 8 sections. Here is what each one does.

1. Variables

Path definitions at the top: where the org source files live, where the HTML gets published, and the FTP server details. All the other functions read from these, so to adapt the setup for a different machine you only touch this section.

(defvar my/blog-base-dir    (expand-file-name "~/site/org/blog/"))
(defvar my/blog-publish-dir (expand-file-name "~/site/public/blog/"))
(defvar my/blog-site-dir    (expand-file-name "~/site/public/"))
(defvar my/blog-ftp-host    "ftp.yourdomain.com")
(defvar my/blog-ftp-user    "youruser")

2. Blog Sections

The my/blog-sections variable defines the categories as a list of (dirname title description) triples:

(defvar my/blog-sections
  '(("learn"          "Learn with Me"
     "Follow along as I work through cryptography challenges...")
    ("rants"          "Rants and Opinions"
     "Thoughts on hardware engineering, open source...")
    ("hardware"       "Hardware and FPGA"
     "Notes on FPGA design, Verilog, processor architectures...")
    ("linux"          "Linux and Dotfiles"
     "From Linux to DEs/WMs, Doom Emacs, themes...")
    ("research-notes" "Research Notes"
     "Write-ups from competitions, conference work...")))

Everything else iterates over this list. Adding a new section is just adding a new entry here.

3. HTML Fragments

The nav bar, CSS links, and theme toggle script all live as Elisp variables here. The preamble function wraps each post’s content in the right divs, the postamble closes them and injects the dark/light mode script.

(defvar my/blog-html-head
  "<link rel=\"stylesheet\" href=\"/style.css?v=8\">
<link rel=\"stylesheet\" href=\"/blog.css?v=17\">")

(defun my/blog--preamble (_plist)
  (concat my/blog-nav "\n<main>\n<div class=\"blog-page\">"))
 
(defun my/blog--postamble (_plist)
  (concat "</div>\n</main>\n" my/blog-theme-script))

Every post gets the same nav, the same CSS, the same JavaScript, all from one place. To change the nav I change it once.

4. Org-Publish Projects

A dolist at load time creates one org-publish project per section and pushes them all into org-publish-project-alist. There is also a composite blog-all project that groups them as :components, so (org-publish "blog-all") handles everything in one shot.

(dolist (section my/blog-sections)
  (let* ((dirname (car section))
         (project-name (concat "blog-" dirname)))
    (push (list project-name
                :base-directory       (concat my/blog-base-dir dirname "/")
                :publishing-directory (concat my/blog-publish-dir dirname "/")
                :publishing-function  'org-html-publish-to-html
                :with-toc             nil
                :with-author          nil
                :section-numbers      nil
                :html-head            my/blog-html-head
                :html-preamble        #'my/blog--preamble
                :html-postamble       #'my/blog--postamble)
          org-publish-project-alist)))
         
(push '("blog-all" :components ("blog-learn" "blog-rants" "blog-hardware" ...))
      org-publish-project-alist)

5. Metadata Extraction

my/blog--get-metadata reads an org file and pulls out title, date, tags, and a preview snippet using regex against the file headers:

(defun my/blog--get-metadata (file)
  (with-temp-buffer
    (insert-file-contents file)
    (let ((title
           (progn (goto-char (point-min))
                  (when (re-search-forward "^#\\+TITLE:[ \t]+\\(.+\\)" nil t)
                    (match-string 1))))
          (date
           (progn (goto-char (point-min))
                  (when (re-search-forward
                         "^#\\+DATE:[ \t]+<?\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)"
                         nil t)
                    (match-string 1))))
          (tags
           (progn (goto-char (point-min))
                  (when (re-search-forward "^#\\+FILETAGS:[ \t]+\\(.+\\)" nil t)
                    (split-string (match-string 1) ":" t "[ \t]+")))))
      (list :title    (or title (file-name-base file))
            :date     (or date "1970-01-01")
            :tags     tags
            :basename (file-name-base file)))))

For the preview it first looks for a #+BEGIN_PREVIEW / #+END_PREVIEW block. If that is not there, it falls back to the first paragraph after the file headers, strips markup like /italics/ and [[links][link text]], and truncates to 100 characters.

6. Index Generation

my/blog-generate-section-index and my/blog-generate-main-index call the metadata scanner, build HTML lists, and write out complete pages. These are plain HTML files, not org-published, built entirely from Elisp string formatting:

(defun my/blog-generate-section-index (dirname title desc)
  (let* ((entries (my/blog--scan-section dirname))
         (entries-html
          (mapconcat
           (lambda (e)
             (format "<li>%s <a href=\"%s/%s.html\">%s</a> &ndash; %s</li>"
                     (plist-get e :date)
                     dirname
                     (plist-get e :basename)
                     (plist-get e :title)
                     (plist-get e :preview)))
           entries "\n"))
         (out-file (concat my/blog-publish-dir dirname ".html")))
    (with-temp-file out-file
      (insert (my/blog--page-wrapper title
               (format "<h1>%s</h1><p>%s</p><ul class=\"blog-list\">%s</ul>"
                       title desc entries-html))))))

7. Post-Processing

org-publish produces the HTML but knows nothing about my tags, dates, or prev/next navigation. So I hook into org-publish-after-publishing-hook to post-process each HTML file right after it is generated.

The function my/blog--inject-tags-after-publish takes the source org file and the output HTML file and does four things:

  1. Reads #+FILETAGS and #+DATE from the org source
  2. Scans the section to find where the current post sits in the timeline
  3. Builds a prev/next nav linking to adjacent posts by date
  4. Opens the HTML and inserts everything in the right spots with regex
;; inject back link before <h1 class="title">
(when (re-search-forward "<h1 class=\"title\">" nil t)
  (goto-char (match-beginning 0))
  (insert back-html))
 
;; inject date right after </h1>
(when (re-search-forward "</h1>" nil t)
  (insert date-html))
 
;; inject tags + prev/next before the closing </div> of the content area
(when (re-search-forward "<div id=\"postamble\"" nil t)
  (re-search-backward "</div>" nil t)
  (insert (concat tags-html nav-html)))

The prev/next logic: posts are sorted newest-first. If the current post is at index i in that list, the newer post is at i-1 and the older post is at i+1. I find the current post by scanning for a matching basename, then grab the neighbors.

8. Commands and Keybindings

Five interactive functions all bound under SPC m b:

SPC m b p : my/blog-publish : publish changed files, regenerate indexes
SPC m b P : my/blog-publish-force : republish everything
SPC m b d : my/blog-deploy : upload to server via lftp
SPC m b a : my/blog-publish-and-deploy : publish then deploy in one go
SPC m b n : my/blog-new-post : prompt for section and title, open file

my/blog-new-post slugifies the title (lowercase, hyphens for spaces and punctuation) and opens the new file with frontmatter already in place:

(defun my/blog-new-post ()
  (interactive)
  (let* ((section-names (mapcar (lambda (s) (cons (nth 1 s) (nth 0 s)))
                                my/blog-sections))
         (chosen      (completing-read "Section: " (mapcar #'car section-names)))
         (section-dir (cdr (assoc chosen section-names)))
         (title       (read-string "Post title: "))
         (slug        (replace-regexp-in-string
                       "[^a-z0-9]+" "-" (downcase title)))
         (filename    (concat my/blog-base-dir section-dir "/" slug ".org")))
    (find-file filename)
    (insert (format "#+TITLE: %s\n#+DATE: %s\n#+AUTHOR: You\n\n"
                    title (format-time-string "<%Y-%m-%d %a>")))))

The deploy function shells out to lftp:

(defun my/blog-deploy ()
  (interactive)
  (let ((cmd (format
              "lftp -e 'mirror --reverse --only-newer --verbose %s /; quit' -u %s %s"
              (expand-file-name my/blog-site-dir)
              my/blog-ftp-user
              my/blog-ftp-host)))
    (async-shell-command cmd "*blog-deploy*")))

--reverse means local to remote (upload). --only-newer skips unchanged files. It runs async so Emacs does not block.

What Works, What Doesn’t

The whole publish + deploy cycle takes a few seconds. Writing a new post is SPC m b n, write, SPC m b a. That part works really well.

The regex post-processing is the part I’m least happy with. It works, but if org-publish ever changes its HTML output structure things will break without much warning. The cleaner approach would be to use libxml-parse-html-region and work with the actual DOM, but the regex version was fast to write and I haven’t had a reason to fix it yet.

The other missing piece is RSS. There is no feed right now, which is annoying. All the metadata is already there, so it would just be another generation function that formats things as XML rather than HTML. It’s on the list.

Loading It

Add this to your config.el:

(after! org (load! "blog" doom-user-dir))

Put blog.el at ~/.config/doom/blog.el, update the path and server variables at the top to match your setup, and you’re done. Section directories get created automatically on load if they don’t exist.

Full source will be on my GitHub soon.