Blog posting with emacs and org-mode

All you need to publish a static website is Emacs and org-mode (and some CSS, bash, and server-setter-upper skills I guess).

This whole website is written using Emacs and org-mode using org publish. Locally, I keep everything in a directory structed as follows:

website/
   ├─╴deploy.sh
   ├─╴src/     
   │   ├─╴publish.el
   │   ├─╴posts/
   │   ├─╴res/
   │   ╰─╴static/
   ╰─╴kryptofisk.dk 
       ├─╴index.html
       ├─╴publications.html
       ├─╴posts/ 
       ╰─╴res/

where src/ has all the .org files, kryptofisk.dk/ has all the compiled HTML, and deploy.sh is a script which uses scp to copy the content of kryptofisk.dk/ to a remote server (the one you're on now.)

The "magic", as it were, happens in publish.el which moves and compiles stuff from src over into kryptofisk.dk in the following manner:

Below is my publish.el. It draws heavily from this blogpost by Dennis Ogbe, albeit an older version of it. (Still, that's the post that started it all).

;;; publish.el --- Utility functions for creating blogs with org mode.
;;
;; Author: Anders Dalskov
;; Copyright: (C) 2026, Anders Dalskov, all rights reserved.
;;
;;; Commentary:
;;
;; This mostly contains an ORG-PUBLISH-PROJECT definition for managing my
;; website.
;;
;; Much of what is in where were inspired by
;; https://ogbe.net/blog/blogging_with_org.html
;;
;;; Code:

(require 'ox-publish)
(require 'ox-html)

(setq org-export-html-coding-system 'utf-8-unix)
(setq org-html-viewport nil)

;; by default org mode generates some CSS based on the current emacs theme. This
;; doesn't play well if emacs is using a dark theme, but the website is using a
;; light one.
(setq org-html-head-include-default-style nil)
(setq org-html-htmlize-output-type 'css)

;; expands to ${pwd}
(setq blog::base-directory (file-name-as-directory (expand-file-name "")))

;; The directory structure is
;;
;;  some_dir/
;;    |--- kryptofisk.dk/
;;    |--- src/
;;
;; and we're current standing in "src/". All the generated HTML goes into the
;; kryptofisk.dk dir.
(setq blog::base-publishing-directory "../kryptofisk.dk")

(setq blog::default-sitemap-filename "index.org")

;; this is used as the header on the website
(setq blog::directories-and-files-alist
  '(("Front" . "/")
    ("Posts" . "/posts")
    ("Publications" . "/publications.html")))

;; Stops `org-publish' from polluting `recentf-list'.
(when (fboundp 'recentf-mode)
  (advice-add #'org-publish :around
              #'(lambda (fun &rest args)
                  (recentf-mode -1)
                  (apply fun args)
                  (recentf-mode 1))))

;; Construct a path of the form "base-dir/dir1/dir2/.../dirn" where base-dir is
;; defined in `blog::base-directory' and `dirs' contains a list of directories to add
;; after.
(defsubst blog::make-directory (&rest dirs)
  (let ((path blog::base-directory))
    (dolist (dir dirs path)
      (setq path (concat (file-name-as-directory path) dir)))))

;; return `blog::base-directory/www/<dir1>/<dir2>...'
(defsubst blog::make-publishing-directory (&rest dirs)
  (apply #'blog::make-directory `(,blog::base-publishing-directory ,@dirs)))

;; write the HTML string displaying the publishing date of an article to the
;; current buffer.
(defsubst blog::insert-published-html (obj)
  (insert (org-timestamp-format (car obj)
           "<p class=\"date\">Published: %Y-%m-%d</p>")))

;; write the HTML with the license string to the current buffer.
(defsubst blog::insert-cc-license-html ()
  (insert "<p class=\"license\">CC BY-SA</p>"))

;; Construct a postamble at the bottom of the page of the form:
;;
;; <hr>
;; License: CC BY-SA
;; Last modified: <some date>
;;
;; In case "published" is set in `options' then these are included as well.
(defun blog::postamble (options)
  (let ((published (plist-get options :date)))
    (with-temp-buffer
      (insert "<hr>")
      (blog::insert-cc-license-html)
      (when published
        (blog::insert-published-html published))
      (buffer-string))))

;; Construct a preamble at the top of the page. The preamble has the form:
;;
;; <link1> <link2> ....
;;
;; Where the different links and their names are defined by the variable
;; `blog::header-links'.
(defun blog::preamble (options)
  (with-temp-buffer
    (insert "<ul>")
    (dolist (name-link-pair blog::directories-and-files-alist)
      (insert (format "<a href=\"%s\">%s</a>" (cdr name-link-pair) (car name-link-pair))))
    (insert "</ul><hr>")
    (buffer-string)))

;; Write the sitemap #+TITLE: field.
(defsubst blog::insert-sitemap-title-html (title-string)
  (insert "#+TITLE: " title-string "\n\n"))

;; Turns a string of the form
;;
;;  "[[file:filename.org][title string]]"
;;
;; Into the full pathname to "filename.org" and the string "title string".
(defun blog::parse-sitemap-item (item)
  (let* ((s1 (split-string item "\\]\\["))
         (s2 (split-string (car s1) "file:"))
         (s3 (split-string (cadr s1) "\\]\\]")))
    (cl-values (blog::make-directory "posts" (cadr s2)) (car s3))))

;; Returns T if the filename starts with "draft_". In that case, the post is
;; treated as in progress and wont be included in the sitemap.
(defun blog::draftp (filename)
  (string-match "^draft_" (file-name-base filename)))

;; Parses the header of a post into a plist that is used to generate the entries
;; for the sitemap.
(defun blog::get-sitemap-entry-info (file)
  (with-temp-buffer
    (insert-file-contents file)
    (goto-char (point-min))
    ;; The order of parsing here is chosen such that we don't have to move point
    ;; back after each parse. It's perhaps a bit too finicky, and I bet it
    ;; doesn't matter performance wise. \shrug
    (let ((published "N/A")
          (tags "\n")
          (preview "\n"))
      ;; published
      (let* ((beg (search-forward "#+DATE:"))
             (end (search-forward "\n"))
             (timestamp (string-trim (buffer-substring beg end))))
        (when (string-empty-p timestamp)
          (error "entry %s missing publishing date" file))
        ;; cannot find any documentation for this guy, but it seems like this
        ;; is how it works.
        (let ((parsed (org-parse-time-string timestamp)))
          (setq published (format-time-string "%B %d, %Y" (encode-time parsed)))))

      ;; tags
      (let* ((beg (search-forward "#+TAGS:"))
             (end (search-forward "\n"))
             (tags-raw (split-string (buffer-substring beg end))))
        (unless (null tags-raw)
          (setq tags (concat ":" (cl-reduce (lambda (x y) (concat x ":" y)) tags-raw) ":\n"))))

      ;; preview
      (let ((beg (1+ (search-forward "#+BEGIN_PREVIEW")))
            (end (and (search-forward "#+END_PREVIEW") (match-beginning 0))))
        (setq preview (buffer-substring beg end)))

      (list :published published :tags tags :preview preview))))

;; Generates the sitemap file for blog posts. Besides the title of the file,
;; this function receives the list of files in the "posts" directory
(defun blog::sitemap (title flist)
  (setf flist-last flist)
  (with-temp-buffer
    (blog::insert-sitemap-title-html title)
    ;; first item in flist is a symbol telling us how the rest of the list is
    ;; structured. We can simply ignore this because the "posts" directory has
    ;; no sub-directories.
    (let ((flist (cdr flist))
          item)
      (while (setq item (car (pop flist)))
        (cl-multiple-value-bind (filename titlestring)
            (blog::parse-sitemap-item item)
          (unless (blog::draftp filename)
            (let ((props (blog::get-sitemap-entry-info filename)))
              (insert "* " titlestring (plist-get props :tags))
              (insert "/" (plist-get props :published) "/\n\n")
              (insert (plist-get props :preview) "\n")
              (insert "[[file: " (file-name-base filename) ".org][>>>]]\n"))))))

    (buffer-string)))

(setq org-html-mathjax-options
      '((path "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js")))

;; project definition.
(setq org-publish-project-alist
      `(("blog"
         :components ("B-posts" "B-image" "B-video" "B-static" "B-audio" "B-files"))

        ;; component for handling posts. Process all files in the src/posts
        ;; directory, compile them to html, and place them in
        ;; kryptofisk.dk/posts.
        ("B-posts"
         :base-directory ,(blog::make-directory "posts")
         :publishing-directory ,(blog::make-publishing-directory "posts")
         :base-extension "org"
         :recursive t
         :htmlized-source t

         :with-author nil
         :with-date t
         :with-toc nil
         :with-creator nil

         :html-doctype "html5"
         :html-link-home ""
         :html-link-up ""
         :html-html5-fancy t
         :html-postamble blog::postamble
         :html-preamble blog::preamble
         :html-head "<link rel=\"stylesheet\" href=\"/res/style.css\" type=\"text/css\"/>"

         :html-mathjax-template "<script type=\"text/javascript\" src=\"%PATH\"></script>"

         :publishing-function org-html-publish-to-html

         :headline-levels 4
         :todo-keywords nil
         :section-numbers nil

         :auto-sitemap t
         :sitemap-filename ,blog::default-sitemap-filename
         :sitemap-date-format "%d-%m-%Y"
         :sitemap-title "Latest posts"
         ;; order posts based on their #+DATE field.
         :sitemap-sort-files anti-chronologically
         :sitemap-function blog::sitemap)

        ;; handles static files, such as the front page and publications page.
        ("B-static"
         :base-directory ,(blog::make-directory "static")
         :publishing-directory ,(blog::make-publishing-directory)
         :base-extension "org"
         :recursive t
         :htmlized-source t

         :with-author nil
         :with-date t
         :with-toc nil
         :with-creator nil
         :headline-levels 4
         :todo-keywords nil
         :section-numbers nil

         :html-doctype "html5"
         :html-html5-fancy t
         :html-postamble blog::postamble
         :html-preamble blog::preamble
         :html-head "<link rel=\"stylesheet\" href=\"/res/style.css\" type=\"text/css\"/>"

         :publishing-function org-html-publish-to-html)

        ;; the rest here are all identical, and all they do (as far as I know)
        ;; is to move files from src/res to kryptofisk.dk/res
        ("B-image"
         :base-directory ,(blog::make-directory "res")
         :publishing-directory ,(blog::make-publishing-directory "res")
         :base-extension "jpg\\|jpeg\\|png\\|gif"
         :publishing-function org-publish-attachment
         :recursive t)

        ("B-video"
         :base-directory ,(blog::make-directory "res")
         :publishing-directory ,(blog::make-publishing-directory "res")
         :base-extension "mp4\\|webm"
         :publishing-function org-publish-attachment
         :recursive t)

        ("B-audio"
         :base-directory ,(blog::make-directory "res")
         :publishing-directory ,(blog::make-publishing-directory "res")
         :base-extension "ogg"
         :publishing-function org-publish-attachment
         :recursive t)

        ("B-files"
         :base-directory ,(blog::make-directory "res" "files")
         :publishing-directory ,(blog::make-publishing-directory "res" "files")
         :base-extension "pdf\\|txt"
         :publishing-function org-publish-attachment
         :recursive t)

        ))

;;; publish.el ends here

All blog posts follow the below template.

#+TITLE: Title goes here
#+AUTHOR: Author goes here
#+DATE: [todays date]
#+TAGS: TAG1 TAG2
#+BEGIN_PREVIEW
This is a preview
#+END_PREVIEW

This is the content

This idea here is very much taken from the original blogpost by Dennis Ogbe. Each post contains a preview segment which is replicated in the sitemap, together with the publishing date and the tags. This information is extracted by the blog::get-sitemap-entry-info.


CC BY-SA

Published: 2026-06-29