Trying weblorg for blogging and my personal site

:emacs: :org: :weblorg:
Feb 14, 2026

Time to start my blog for the third time. The first two times I got stuck either through complex workflows or I didn't get used to write for myself.

As I think there are several benefits of writing stuff down, I give my blog a revive.

1. Introduction

The last time I tried hugo together with ox-hugo and easy-hugo. The setup was fairly easy but required working with different programs and abstractions and I didn't automate the publishing part.

I think these obstacles are the major reason that the blogging doesn't work well for myself. This time I wanted a better integration into org mode and Emacs and publishing workflow which is done by some CI.

This time I gave weblorg a try. The journey was a mixed experience and I think the setup process should deserve some explanation, to help other struggling with getting used to weblorg.

2. Setup weblorg

The setup was straight forward following the official documentation.

I started with a minimal example and created the directory structure as described by the weblorg documentation:

blog/
|- posts/
|- pages/
|- publish.el

the structure is fairly simple as the posts folder contains the blog posts and the pages folder all other pages.

The publish.el file serves as definition and export script. I followed closely the example from the documentation and only added my own template variables.

The first version of my publish.el:

(require 'org)
(require 'weblorg)

(weblorg-site
 :template-vars '(("site_name" . "rtzptz")
                  ("site_description" . "puzzling void")
                  ("site_owner" . "ratzeputz@rtzptz.xyz")))

(weblorg-route
 :name "posts"
 :input-pattern "posts/*.org"
 :template "post.html"
 :output "output/posts/{{ slug }}.html"
 :url "/posts/{{ slug }}.html")

;; route for rendering the index page of the blog
(weblorg-route
 :name "blog"
 :input-pattern "posts/*.org"
 :input-aggregate #'weblorg-input-aggregate-all-desc
 :template "blog.html"
 :output "output/index.html"
 :url "/")

;; route for rendering each page
(weblorg-route
 :name "pages"
 :input-pattern "pages/*.org"
 :template "page.html"
 :output "output/{{ slug }}.html"
 :url "/{{ slug }}.html")

;; route for static assets that also copies files to output directory
(weblorg-copy-static
 :output "static/{{ file }}"
 :url "/static/{{ file }}")

;; fire the engine and export all the files declared in the routes above
(weblorg-export)

As one can see, weblorg uses route definitions for matching input files with templates. Running (weblorg-export) at the end generates the defined output HTML files.

The first export works pretty well but I stumbled that not all pages I defined got generated. As the template engine and the default template are not obvious present in the documentation, I tinkered a while before I dove into the source code.

The default template only supports the page about and no other pages. Supporting arbitrary pages and dumping the default template would greatly improve the first-user experience. I think its natural that every user rolls it own template and thus the default should be easily accessible from inside Emacs. To start with out own template, see the weblorg-template repository and templatel. Note that the templating engine only supports a subset of the jinja templating specification.

3. Useful helpers

weblorg comes with everything in-place to get started but doesn't offer some quality-of-life functions for writing posts or running the export. Well it's Emacs just roll out your own.

;; A simple http server which is used upon site generation
(use-package simple-httpd
  :ensure t)

;; Define blog constants
(defconst jj-site-path
  (expand-file-name "~/repos/rtzptz-site/"))
(defconst jj-site-posts-path (expand-file-name "posts" jj-site-path))
(defconst jj-site-local-dev-p t)
(defconst jj-time-format "%Y-%m-%d")

;; Helper functions to quickly access the blog content or files
(defun jj-site-open-file (file-name)
  "Ask the user which blog file to open."
  (interactive (list (read-file-name "File:" jj-site-path)))
  (find-file file-name))

(defun jj-site-open-post (file-name)
  "Ask the user which blog post should be opened?"
  (interactive
   (list (read-file-name "Post:"
                         (expand-file-name "posts/" jj-site-path))))
  (find-file file-name))

;; Generate the blog and start a local webserver
(defun jj-site-generate ()
  "Evaluate the publish.el file in the website path."
  (interactive)
  (let* ((publish "publish.el")
         (old-default-directory default-directory))
    (save-excursion
      (delete-directory (expand-file-name "output" jj-site-path) t)
      (find-file-noselect (expand-file-name publish jj-site-path))
      (setq-local default-directory jj-site-path)
       (eval-buffer publish)
       (setq-local default-directory old-default-directory)))
  (unless (httpd-running-p)
    (setq httpd-root (expand-file-name "output" jj-site-path))
    (httpd-start)))

;; Generate a new blog post with the file format <Y-m-d>-<name>
(defun jj-site--post-template ()
  (mapconcat #'identity
             '("#+TITLE: %s\n"
               "#+SLUG:\n"
               "#+DATE:\n"
               "#+DRAFT: t\n"
               "#+FILETAGS:\n"
               "\n"
               "Write something")))

(defun jj-org--slugify-name (name)
  (let* ((s (downcase name))
         (s (replace-regexp-in-string "[[:space:]]+" "-" s)))
    (if (string-empty-p s) "untitled" s)))

(defun jj-site-create-post (post-title)
  "Creates a new blog post from a template."
  (interactive (list (read-string "Post name:")))
  (let* ((slug (jj-org--slugify-name post-title))
         (file-name (concat (format-time-string jj-time-format (current-time))
                            "-" slug ".org"))
         (file-path (expand-file-name file-name jj-site-posts-path)))
    (message "%s" file-path)
    (unless (file-exists-p file-path)
      (with-temp-file file-path
        (insert (format (jj-site--post-template) post-title))
        (unless (bolp) (insert "\n"))))
    (find-file file-path)
    (goto-char (point-min))
    (end-of-line)))

With these helper function I can quickly open my blog posts, view the blog on my local machine and create new post files. For the moment this is enough to get me working.

The simple-httpd server starts at http://localhost:8080/ and runs as long as either Emacs is running or it is killed with (httpd-stop).

4. CD with codeberg

The last part is to setup a CD workflow. I want to push the latest changes to a git repository at Codeberg and let the CD handle the generation of the site and how it gets published on my server.

One note: Codebarg uses the Woodpecker CI and every project needs to get a manual approve and the project needs to be public. See this repository and this one for the process. The access is typically granted within a day and one can managed it under ci.codeberg.org.

If you have experience with Github Actions, the Woodpecker CI feels quite familiar. The deployment is straight forward and I noticed only one main difference. Woodpecker allows a new docker image for every step which uses the same persistent storage in the background.

when:
  - event: push
    branch: main
  - event: manual

steps:
  - name: build
    image: debian:trixie-slim
    commands:
      - apt-get update
      - apt-get install -y --no-install-recommends emacs-nox git ca-certificates
      - emacs --batch --script publish.el

  - name: deploy
    image: alpine:3.20
    commands:
      - apk add --no-cache openssh-client rsync
      - mkdir -p ~/.ssh
      - printf "%s\n" "$${SSH_KEY}" > ~/.ssh/id_ed25519
      - chmod 600 ~/.ssh/id_ed25519
      # besser: known_hosts als Secret pflegen; ssh-keyscan ist pragmatisch
      - ssh-keyscan -H $$DEPLOY_HOST >> ~/.ssh/known_hosts
      - rsync -avz --delete output/ "$${DEPLOY_USER}@$${DEPLOY_HOST}:$${DEPLOY_PATH}/"
    environment:
      DEPLOY_HOST:
        from_secret: deploy_host
      DEPLOY_USER:
        from_secret: deploy_user
      DEPLOY_PATH:
        from_secret: deploy_path
      SSH_KEY:
        from_secret: deploy_ssh_key

As everything was in place and I'm very happy to get the whole stuff done in an evening, I only saw failed CI actions. It took me a while as the Emacs and weblorg trace where not really readable (weblorg needs better error messages), that the org version provided by the CI Emacs version doesn't fit to the weblorg expectations. Thus, I needed to adjust the publish.el script to use the latest org version.

This is the adjusted script file:

;; Setup package archive for org and weblorg
(setq package-archives
      '(("gnu"    . "https://elpa.gnu.org/packages/")
        ("nongnu" . "https://elpa.nongnu.org/nongnu/")
        ("melpa"  . "https://melpa.org/packages/")))

(package-initialize)

(unless package-archive-contents
  (package-refresh-contents))

(when (boundp 'package-install-upgrade-built-in)
  (setq package-install-upgrade-built-in t))

(package-install 'org)
(message "OK: Org loaded: %s  (%s)" (org-version) (locate-library "org"))

(unless (package-installed-p 'weblorg)
  (package-refresh-contents)
  (package-install 'weblorg t))

(require 'weblorg)

;; Rest of the file

Now it works stable across the CI and my local machine.

To sum this up:

  • The weblorg user experience is fine but some QoL functions can greatly improve the experience.
  • The setup is fairly simple and works for CD deployment.
  • The external programs I used prior to this setup often got in my way and this pure Emacs setup feels more naturally.

Feel free to write me and suggest improvements or for general discussions.