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> – %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:
- Reads
#+FILETAGSand#+DATEfrom the org source - Scans the section to find where the current post sits in the timeline
- Builds a prev/next nav linking to adjacent posts by date
- 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.