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:
- All
.orgfiles get compiled to HTML. - All
.orgfiles insrc/posts/get compiled and moved intokryptofisk.dk/posts/. In addition information in these files are collected into a sitemap file located atkryptofisk.dk/posts/index.html. - Mostly everything in
src/resgets moved tokryptofisk.dk/reswithout any kind of processing (this is for images, stylesheet, and so on). - All
.orgfiles insrc/staticget compiled and placed in thekryptofisk.dk/. This is the where theindex.htmlandpublications.htmlcome from.
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.