Migrating website to orgmode
Table of Contents
The last couple of years I've been a content user of the excellent Zola static website generator, using it to create the website for our work. However, as Zola is software in development, it breaks, and with it my ability to update the website. Instead of writing interesting stuff about solving technical problems or posting updates on our performances I am reduced to trying to figure out why a certain shortcode no longer works and so on and so forth. I really don't want that kind of debugging experience.
Secondly, Zola works with markdown documents. I much prefer working in Org mode within the confines of Emacs.
Thirdly, up until now our website was hosted by Gitlab.com. I have moved all my code hosting over to a more ethical and European alternative, Codeberg.org, instead, but the website was still lingering on the other side of the ocean.
And, finally, I used Bootstrap, an enormous CSS library with far too many bells and whistles. In general I don't want to be dependent on huge frameworks more than absolutely necessary, so it was time to boot the bootstrap.
Adding it all up, it became clear that the moment had arrived to port the website over to orgmode, simplify the css, and bring it over to a new host.
Starting point: build-site.el
The foundation for the project was Systemcrafters' excellent tutorial on how to build and deploy a website. All I had to do was to expand a bit from the basics that were laid out there. At the core of this is the build script that sets up the whole process:
;; Set the package installation directory so that packages aren't stored in the ;; ~/.emacs.d/elpa path. (require 'package) (setq package-user-dir (expand-file-name "./.packages")) (setq package-archives '(("melpa" . "https://melpa.org/packages/") ("elpa" . "https://elpa.gnu.org/packages/"))) ;; Initialize the package system (package-initialize) (unless package-archive-contents (package-refresh-contents)) ;; Install dependencies (package-install 'htmlize) ;; Please don't use the horrifying default styling for syntax highlighting... (setq org-html-htmlize-output-type 'css) ;; Load the publishing system (require 'ox-publish) ;; Customize the HTML output (setq org-html-validation-link nil ;; Don't show validation link org-html-head-include-scripts nil ;; Use our own scripts org-html-head-include-default-style nil ;; Use our own styles org-html-head (concat "<link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\" />" "<link rel=\"stylesheet\" href=\"/custom.css\">") org-html-head-extra "<script async defer data-domain=\"roosnaflak.com\" src=\"https://plausible.io/js/plausible.js\"></script>") ;; From here: https://ogbe.net/blog/emacs_org_static_site#orgd56757b (setq website-content-dir (concat (file-name-as-directory (file-name-directory (directory-file-name (file-name-directory (or load-file-name buffer-file-name))))) "roosnaflak-org-mode/content")) (defun make-slideshow (dir) "Make a slideshow from images in a DIR." (let* ((div-begin "<div class=\"slideshow-container\">") (dirfiles (directory-files dir nil ".jpg$")) (num-images (length dirfiles)) (count 0) (body (mapconcat 'identity (mapcar (lambda (file) (setq count (+ count 1)) (format "\n<div class=\"mySlides fade\">\n<div class=\"numbertext\">%d/%d</div>\n<img src=\"%s/%s\" style=\"width:100%%\">\n</div>" count num-images dir file)) dirfiles) " ")) (div-end "<a class=\"prev\" onclick=\"plusSlides(-1)\">❮</a><a class=\"next\" onclick=\"plusSlides(1)\">❯</a></div><br>") (dots "<div style=\"text-align:center\">") (js "<script src=\"/js/slideshow.js></script>")) (dotimes (i count) (setq dots (concat dots (format "<span class=\"dot\" onclick=\"currentSlide(%d)\"></span>" (+ i 1))))) (concat "#+BEGIN_EXPORT html\n" div-begin "\n" body "\n" div-end "\n" dots "\n" ;; js "\n#+END_EXPORT"))) (setq org-export-global-macros '((vimeo . "#+HTML:<div style=\"padding:56.25% 0 0 0;position:relative;\"><iframe src=\"https://player.vimeo.com/video/$1?badge=0&autopause=0&player_id=0&app_id=58479\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" style=\"position:absolute;top:0;left:0;width:100%;height:100%;\"></iframe></div><script src=\"https://player.vimeo.com/api/player.js\"></script>") (slideshow . "(eval (make-slideshow $1))"))) (defun website-nav () (with-temp-buffer (insert-file-contents (concat website-content-dir "/header.html")) (buffer-string))) (defun website-footer () (with-temp-buffer (insert-file-contents (concat website-content-dir "/footer.html")) (buffer-string))) ;; Define the publishing project (setq org-publish-project-alist (list (list "org-site:main" :recursive t :base-directory "./content" :publishing-function 'org-html-publish-to-html :publishing-directory "./public" :with-author nil ;; Don't include author name :with-creator nil ;; Don't include Emacs and Org versions in footer :with-toc nil ;; Don't include table of contents :section-numbers nil ;; Don't include section numbers :recurse t :time-stamp-file nil :html-preamble (website-nav) :html-postamble (website-footer)) (list "static" :base-directory "./content" :base-extension "svg\\|js\\|css\\|txt\\|jpg\\|gif\\|png\\|pdf" :recursive t :publishing-directory "./public" :publishing-function 'org-publish-attachment))) ;; Generate the site output (org-publish-all t) (message "Build complete!")
This is then run from a bash script like this:
#!/bin/sh echo "Running build script" emacs -Q --script build-site.el
The great thing about this is that you can glue together the necessary bits and pieces of the website, so that you don't have to recreate the header and footer for each page you're making.
CSS
The esthetics of a raw html page when rendered in your browser is — not great. Enter Simple CSS, a beautiful stylesheet that turns your site into something nice without bloat. I particularly like the dark theme. You can override whatever is defined in the stylesheet by adding a custom.css loaded after the main .css file:
(setq org-html-head "<link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\" />
<link rel=\"stylesheet\" href=\"/custom.css\">")
My custom.css looks like this, mostly with enhancements for displaying images in a grid, creating slideshows, as well as a responsive navbar on the top of the page:
:root { color-scheme: dark; --bg: #212121; --accent-bg: #2b2b2b; --text: #dcdcdc; --text-light: #ababab; --accent: #ffb300; --accent-hover: #ffe099; --accent-text: var(--bg); --code: #f06292; --preformatted: #ccc; --disabled: #111; } a:link { text-decoration: none; } a:visited { text-decoration: none; } a:hover { text-decoration: none; font-weight: bold; } a:active { text-decoration: none; } /* Add a black background color to the top navigation */ .topnav { background-color: #101010; overflow: hidden; } /* Style the links inside the navigation bar */ .topnav a { float: left; display: block; /* color: #f2f2f2; */ text-align: center; padding: 14px 16px; text-decoration: none; font-size: 17px; } .topnav .icon { display: none; } /* When the screen is less than 600 pixels wide, hide all links, except for the first one ("Home"). Show the link that contains should open and close the topnav (.icon) */ @media screen and (max-width: 600px) { .topnav a:not(:first-child) {display: none;} .topnav a.icon { float: right; display: block; } } /* The "responsive" class is added to the topnav with JavaScript when the user clicks on the icon. This class makes the topnav look good on small screens (display the links vertically instead of horizontally) */ @media screen and (max-width: 600px) { .topnav.responsive {position: relative;} .topnav.responsive a.icon { position: absolute; right: 0; top: 0; } .topnav.responsive a { float: none; display: block; text-align: left; } } /* Format headers */ h1 { font-size: 2.6rem; } h2 { font-size: 2rem; margin-top: 3rem; } h3 { font-size: 1.44rem; margin-top: 3rem; } h4 { font-size: 1.15rem; } h5 { font-size: 0.96rem; } h6 { font-size: 0.96rem; } .container { display: flex; flex-wrap: wrap; } .container div { margin: 0px; padding: 2px; font-size: 30px; flex: 33.33%; } /* Make a one column-layout instead of three-column layout */ @media (max-width: 600px) { .container div { flex: 100%; } } figcaption{ font-size: 1.15rem; color: var(--text); } div.email > span:nth-child(2) { display: none; } * {box-sizing:border-box} /* Slideshows */ /* Slideshow container */ .slideshow-container { max-width: 1000px; position: relative; margin: auto; } /* Hide the images by default */ .mySlides {display: none} /* img {vertical-align: middle;} */ /* Next & previous buttons */ .prev, .next { cursor: pointer; position: absolute; top: 50%; width: auto; padding: 16px; margin-top: -22px; color: white; font-weight: bold; font-size: 18px; transition: 0.6s ease; border-radius: 0 3px 3px 0; user-select: none; } /* Position the "next button" to the right */ .next { right: 0; border-radius: 3px 0 0 3px; } /* On hover, add a black background color with a little bit see-through */ .prev:hover, .next:hover { background-color: rgba(0,0,0,0.8); } /* Caption text */ .text { color: #f2f2f2; font-size: 15px; padding: 8px 12px; position: absolute; bottom: 8px; width: 100%; text-align: center; } /* Number text (1/3 etc) */ .numbertext { color: #f2f2f2; font-size: 12px; padding: 8px 12px; position: absolute; top: 0; } /* The dots/bullets/indicators */ .dot { cursor: pointer; height: 15px; width: 15px; margin: 0 2px; background-color: #bbb; border-radius: 50%; display: inline-block; transition: background-color 0.6s ease; } .active, .dot:hover { background-color: #717171; } /* Fading animation */ .fade { animation-name: fade; animation-duration: 1.5s; } @keyframes fade { from {opacity: .4} to {opacity: 1} } /* On smaller screens, decrease text size */ @media only screen and (max-width: 300px) { .prev, .next,.text {font-size: 11px} }
Migration issues
Migrating a whole bunch of markdown files to org-mode was far from painless. Porting over the information from the yaml frontmatter turned out to be the main nut to crack. The most important information in the frontmatter was the title of the page, the date of publication, and sometimes a vimeo id.
However, the first port of call was getting the documents converted. Luckily the gods have seen fit to create pandoc, an absolutely insane converter tool. I wrote this little script to batch convert all the .md files in one folder and dump the resulting .org files in another:
#!/bin/bash indir=$1 outdir=$2 for file in $indir/*.md do fs=${file##*/} pandoc -f markdown -t org -o $outdir/${fs%%.md}.org $file done
Great.
Now that the files exist, I had to try and extract some frontmatter information. Cue next script:
#!/bin/bash dir=$1 for f in $dir/*.org do sed -r -i 's/title = "(.*)"/\n#+TITLE: \1\n/' $f sed -r -i 's/date = ["]*([[:digit:]]{4}-[[:digit:]]{2}-[[:digit:]]{2})/\n\/Published on: \1\/\n/' $f sed -r -i 's/^\+\+\+//g' $f done
Good ol' unix-ing with sed to do some regexp replacements. This works for quite a lot of the cases, but not all. The problem was compounded by the fact that I haven't been consistent in my layout of the frontmatter over the years, nor have I been consistent in the date format. So much manual cleaning-upping still had to be done, providing a great opportunity to practice my emacs-fu.
Export macros
After a whole bunch of pasting raw html vimeo embed codes into the various pages, I thought that there must be a better way. And this being emacs, the most advanced piece of software known to mankind, of course there is a better way. A much better way. Enter macros:
(setq org-export-global-macros '((vimeo . "#+HTML:<div style=\"padding:56.25% 0 0 0;position:relative;\"><iframe src=\"https://player.vimeo.com/video/$1?badge=0&autopause=0&player_id=0&app_id=58479\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" style=\"position:absolute;top:0;left:0;width:100%;height:100%;\"></iframe></div><script src=\"https://player.vimeo.com/api/player.js\"></script>") (slideshow . "(eval (make-slideshow $1))")))
With this I can simply write {{{vimeo(123456677)}}} anywhere in any file and get the embedded vimeo player.
The slideshow is another piece of genius that iterates over each image in a directory and creates a slideshow from them. The heavy lifting is done by the make-slideshow function:
(defun make-slideshow (dir) "Make a slideshow from images in a DIR." (let* ((div-begin "<div class=\"slideshow-container\">") (dirfiles (directory-files dir nil ".jpg$")) (num-images (length dirfiles)) (count 0) (body (mapconcat 'identity (mapcar (lambda (file) (setq count (+ count 1)) (format "\n<div class=\"mySlides fade\">\n<div class=\"numbertext\">%d/%d</div>\n<img src=\"%s/%s\" style=\"width:100%%\">\n</div>" count num-images dir file)) dirfiles) " ")) (div-end "<a class=\"prev\" onclick=\"plusSlides(-1)\">❮</a><a class=\"next\" onclick=\"plusSlides(1)\">❯</a></div><br>") (dots "<div style=\"text-align:center\">")) (dotimes (i count) (setq dots (concat dots (format "<span class=\"dot\" onclick=\"currentSlide(%d)\"></span>" (+ i 1))))) (concat "#+BEGIN_EXPORT html\n" div-begin "\n" body "\n" div-end "\n" dots "\n" "\n#+END_EXPORT")))
For this to work you need a javascript file somewhere after the slideshow, so I stuck this directly above the footer:
let slideIndex = 1; showSlides(slideIndex); function plusSlides(n) { showSlides(slideIndex += n); } function currentSlide(n) { showSlides(slideIndex = n); } function showSlides(n) { let i; let slides = document.getElementsByClassName("mySlides"); let dots = document.getElementsByClassName("dot"); if (n > slides.length) {slideIndex = 1} if (n < 1) {slideIndex = slides.length} for (i = 0; i < slides.length; i++) { slides[i].style.display = "none"; } for (i = 0; i < dots.length; i++) { dots[i].className = dots[i].className.replace(" active", ""); } slides[slideIndex-1].style.display = "block"; dots[slideIndex-1].className += " active"; }
All of the slideshow code is taken from w3schools (in fact, so is most of the code for the navbar, come to think of it).
Running and deploying
In order to do local development of a website you need a server. With zola you can simply run zola serve and it will keep serving the pages on localhost, as well as automatically rebuilding any pages that have changed in the meantime. Unfortunately this luxury is denied me (as of yet, I'm sure there are ways to accomplish this…). However, serving pages turns out to be very simple:
#bin/bash cd public python3 -m http.server
This serves the pages to localhost:8000 and can be viewed from the browser.
In the end, though, there are a bunch of operations that would be very tedious to perform manually from a terminal every time they are required. A few weeks ago I was made aware of justfiles, which is perfect for these kinds of things. There is an emacs package for this as well: justl.el, that provides a convenient interface to all the different stuff that is needed. I created a justfile at the root of the project:
# List all available tasks
default:
just -l
# Build the site
build:
./build.sh
# Serve it to localhost:8000
serve:
./serve.sh
# Build and serve in one go
build-and-serve:
./build-and-serve.sh
# Remove all generated artifacts in PUBLIC
clean:
./clean.sh
# Build and deploy website to codeberg.page
deploy:
./deploy.sh
The deploy script wraps up everything in one go and uploads the files to codeberg:
#!/bin/bash set -euo pipefail timestamp=$(date +%F_%T) cd /home/kf/html/roosnaflak-org-mode ./build.sh echo "Committing and uploading source code" git add * git commit -m "Built and deployed on $timestamp" git push origin main echo "Committing and uploading generated artifacts" cd public git add * git commit -m "Built and deployed on $timestamp" git push origin main
Notice that there are two git repositories at play here. The main repository is the one with all the source code, the public repository is where the final website pages reside and are served as a website to the real world.
Hosting
After all of this the time had finally come to serve the page to the world. When you upload a page to codeberg it will be served as https://user.codeber.page. This is a great way to test everything before going fully public with the site, just to make sure that whatever you did on your local machine actually survives the transition to the big bad outdoors.
In order for this to happen, though, there are a few steps to be had:
- Ensure that the repo is called
pages - Go to Settings at the top right of your repository page.
- Click Webhooks in the sidebar on the left.
- Click the Add webhook button in the top right corner of the webhook settings page.
- Select Forgejo from the drop-down list of webhook types.
- Enter https://username.codeberg.page/ as the Target URL, replacing the username with your Codeberg username or organization name. This is the URL your website will be available from.
- Set the Branch filter to pages.
- Click the Add webhook button at the bottom of the settings page.
- Push or upload your content to the pages branch, and it will automatically be published to https://username.codeberg.page/.
This is current as of 2026-02-23, according to the codeberg pages documentation. On the same page you will find the steps for using a custom domain, which is probably what you want to do.
In my case this was a bit more fiddly than I expected, but in the end it worked out. First of all I had to delete the existing records pointing to gitlab, and then add the correct records for codeberg. After this I ended up with:
A @ 217.197.84.141 AAAA @ 2a0a:4580:103f:c0de::2 txt @ pages.kflak.codeberg.page cname www roosnaflak.com
I also had to make sure that a text document called .domains existed at the root of the repo with the following content:
roosnaflak.com www.roosnaflak.com kflak.codeberg.page pages.kflak.codeberg.page
I still had to wait for a while for the certificates to propagate, so if you do this and don't see your page immediately, don't panic. Grab a cup of tea, go for a walk, and at some point the page should materialize.
And that's it! Migration complete, me happy, and now on to other, more interesting things than web development.