#+TITLE: Setup #+OPTIONS: toc:nil num:nil * Introduction This page will highlight the ~build-script.el~ used to generate this website. This entire website is generated through org files using the command: #+BEGIN_SRC bash emacs -Q --script build-site.el #+END_SRC * The script The script is as follows, at the start I have some metadata relating to the file, followed by package management,then the declaration of variables/functions and finally the ~org-publish-project-alist~ which handles nearly all of the project generation instructions. /The following code snippet will probably change often, as I can never seem to stick to a single configuration 😀/ #+BEGIN_SRC emacs-lisp ;;; build-site.el --- Publish my website using org-publish -*- lexical-binding: t; -*- ;; Author: Zaine Qayyum ;; Created: 2025-08-08 ;; Purpose: Build and publish my static site from Org files. ;;; Commentary: ;; Run this file with: ;; emacs -Q --script build-site.el ;;; Code: (require 'package) (setq package-user-dir (expand-file-name "./.packages")) (setq package-archives '(("melpa" . "https://melpa.org/packages/") ("elpa" . "https://elpa.gnu.org/packages/"))) (require 'ox-publish) (require 'cl-lib) (require 'org) ;; Initialize the package system (package-initialize) (unless package-archive-contents (package-refresh-contents)) ;; Install dependencies (package-install 'htmlize) ;; Load the publishing system (setq org-html-htmlize-output-type 'css) (setq org-html-head-include-default-style nil) (defvar z-shared-head " ") (defvar z-preamble "
\"Site
Updated: %C
" ) (defvar z-postamble "") (defun z/posts-sitemap (title list) "sitemap that lists post links as bullet points with dates and tags." (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil \n\n" "See the categories: @@html:Categories@@\n\n" "* Posts:\n" (mapconcat (lambda (entry) (let* ((link (car entry)) ;; extract relative file name from the link (filename (if (string-match "\\[\\[file:\\([^]]+\\)\\]" link) (match-string 1 link) link)) (full-path (expand-file-name filename "~/master-folder/org_files/org_web/posts/")) (date-str "no date") (tags-str "")) ;; Get publish date (let ((date (org-publish-find-date full-path org-publish-project-alist))) (when date (setq date-str (format-time-string "%Y-%m-%d" date)))) ;; Get FILETAGS from file buffer (when (file-exists-p full-path) (with-temp-buffer (insert-file-contents full-path) (org-mode) (let* ((tags (cadr (assoc "FILETAGS" (org-collect-keywords '("FILETAGS")))))) (when tags (setq tags-str (mapconcat (lambda (tag) (format "@@html:%s@@" tag)) (split-string tags ":" t) ;; <- Splits by ":" and removes empty strings " ")))))) ;; Final line output (format "- %s @@html:%s@@ %s" link date-str tags-str))) (cdr list) "\n"))) (defun z/blogs-sitemap (title list) "sitemap that lists blog links as bullet points with dates and tags." (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil \n\n" "See the categories: @@html:Categories@@\n\n" "* Blogs:\n" (mapconcat (lambda (entry) (let* ((link (car entry)) ;; extract relative file name from the link (filename (if (string-match "\\[\\[file:\\([^]]+\\)\\]" link) (match-string 1 link) link)) (full-path (expand-file-name filename "~/master-folder/org_files/org_web/blogs/")) (date-str "no date") (tags-str "")) ;; Get publish date (let ((date (org-publish-find-date full-path org-publish-project-alist))) (when date (setq date-str (format-time-string "%Y-%m-%d" date)))) ;; Get FILETAGS from file buffer (when (file-exists-p full-path) (with-temp-buffer (insert-file-contents full-path) (org-mode) (let* ((tags (cadr (assoc "FILETAGS" (org-collect-keywords '("FILETAGS")))))) (when tags (setq tags-str (mapconcat (lambda (tag) (format "@@html:%s@@" tag)) (split-string tags ":" t) ;; <- Splits by ":" and removes empty strings " ")))))) ;; Final line output (format "- %s @@html:%s@@ %s" link date-str tags-str))) (cdr list) "\n"))) (defun z/categories-sitemap (title _list) "Generate a categories page by scanning tags across org-posts and org-blogs." (let* ((expanded (org-publish-expand-projects org-publish-project-alist)) (posts (assoc "org-posts" expanded)) (blogs (assoc "org-blogs" expanded)) ;; DO NOT set :exclude to "" — it excludes everything (files (cl-remove-duplicates (append (and posts (org-publish-get-base-files posts)) (and blogs (org-publish-get-base-files blogs))) :test #'file-equal-p)) ;; optional: drop the generated sitemaps (files (cl-remove-if (lambda (f) (member (file-name-nondirectory f) '("posts-list.org" "blogs-list.org" "sitemap.org" "categories.org"))) files)) (counts (make-hash-table :test 'equal))) ;;(message "files I will scan: %S" files) (dolist (f files) (when (file-readable-p f) (with-temp-buffer (insert-file-contents f) (org-mode) (let* ((kw (org-collect-keywords '("FILETAGS" "TAGS"))) (raw (car (or (cdr (assoc "FILETAGS" kw)) (cdr (assoc "TAGS" kw)))))) (when raw (dolist (tag (split-string raw ":" t)) (puthash tag (1+ (gethash tag counts 0)) counts))))))) (let (tags) (maphash (lambda (k _) (push k tags)) counts) (setq tags (sort tags #'string-lessp)) (concat "#+TITLE: " title "\n#+OPTIONS: toc:nil num:nil title:nil\n\n* Categories (Includes both blogs and posts)\n" (if tags (mapconcat (lambda (tag) (format "- [[file:tags/%s.org][@@html:%s@@]] (%d)" (z/tag-slug tag) tag (gethash tag counts)) ) tags "\n") "_No tags found yet._"))))) (defun z/tag-slug (s) "Turn a tag into a safe filename." (let ((down (downcase s))) (replace-regexp-in-string "[^a-z0-9]+" "-" down))) (defun z/collect-post-files () "Return all .org files from org-posts and org-blogs." (let* ((expanded (org-publish-expand-projects org-publish-project-alist)) (posts (assoc "org-posts" expanded)) (blogs (assoc "org-blogs" expanded))) (cl-remove-duplicates (append (and posts (org-publish-get-base-files posts)) (and blogs (org-publish-get-base-files blogs))) :test #'file-equal-p))) (defun z/gather-tag-index () "Return hash: tag -> list of (FILE TITLE DATE-ISO)." (let ((idx (make-hash-table :test 'equal))) (dolist (f (z/collect-post-files)) (when (and (string-match-p "\\.org\\'" f) (file-readable-p f) ;; ignore generated lists (not (member (file-name-nondirectory f) '("posts-list.org" "blogs-list.org" "sitemap.org" "categories.org")))) (with-temp-buffer (insert-file-contents f) (org-mode) (let* ((kw (org-collect-keywords '("TITLE" "FILETAGS" "TAGS" "DATE"))) (title (or (car (cdr (assoc "TITLE" kw))) (file-name-base f))) (date (or (car (cdr (assoc "DATE" kw))) "")) ;; optional (raw (car (or (cdr (assoc "FILETAGS" kw)) (cdr (assoc "TAGS" kw)))))) (when raw (dolist (tag (split-string raw ":" t)) (push (list f title date) (gethash tag idx)))))))) idx)) (defun z/write-tag-pages () "Generate tags/*.org pages listing posts for each tag." (let* ((site-root (expand-file-name "~/master-folder/org_files/org_web/")) (tags-dir (expand-file-name "tags" site-root))) (unless (file-directory-p tags-dir) (make-directory tags-dir t)) (let ((idx (z/gather-tag-index))) (maphash (lambda (tag items) (let* ((slug (z/tag-slug tag)) (outfile (expand-file-name (format "%s.org" slug) tags-dir))) (with-temp-file outfile (insert (format "#+TITLE: Tag: %s\n#+OPTIONS: toc:nil num:nil title:nil \n\n* Posts tagged %s\n" tag tag)) ;; sort newest first if DATE present (setq items (sort items (lambda (a b) (string> (nth 2 a) (nth 2 b))))) (dolist (it items) (let* ((file (nth 0 it)) (title (nth 1 it)) (rel (file-relative-name file tags-dir))) ;; link to the source .org; org-publish will rewrite to the .html (insert (format "- [[file:%s][%s]]\n" rel title))))))) idx) ))) ;; Define the publishing project (setq org-publish-project-alist `(("org-main" :recursive t :base-directory "~/master-folder/org_files/org_web/" :publishing-function org-html-publish-to-html :publishing-directory "~/master-folder/org_files/org_web/output" :with-author nil :with-creator t :with-toc t :base-extension "org" :section-numbers nil :time-stamp-file nil :auto-sitemap t :sitemap-filename "sitemap.org" :sitemap-title "Sitemap" :sitemap-sort-files chronologically :html-preamble ,z-preamble :html-postamble ,z-postamble :html-head ,z-shared-head) ("org-categories-sitemap" :recursive t :base-directory "~/master-folder/org_files/org_web/" :publishing-directory "~/master-folder/org_files/org_web/output" :base-extension "org" :auto-sitemap t :sitemap-filename "categories.org" :sitemap-title "Categories" :sitemap-function z/categories-sitemap :html-preamble ,z-preamble :html-postamble ,z-postamble :html-head ,z-shared-head) ("org-posts" :base-directory "~/master-folder/org_files/org_web/posts" :publishing-directory "~/master-folder/org_files/org_web/output/posts/" :recursive t :base-extension "org" :publishing-function org-html-publish-to-html :with-author nil :with-creator nil :html-validation-link nil :with-toc t :section-numbers t :html-preamble ,z-preamble :html-postamble ,z-postamble :auto-sitemap t :sitemap-filename "posts-list.org" :sitemap-title "Posts List" :sitemap-style list :sitemap-function z/posts-sitemap :html-head ,z-shared-head) ("org-blogs" :base-directory "~/master-folder/org_files/org_web/blogs" :publishing-directory "~/master-folder/org_files/org_web/output/blogs/" :recursive t :base-extension "org" :publishing-function org-html-publish-to-html :with-author nil :with-creator nil :html-validation-link nil :html-preamble ,z-preamble :html-postamble ,z-postamble :auto-sitemap t :sitemap-filename "blogs-list.org" :sitemap-title "Blogs List" :sitemap-style list :sitemap-function z/blogs-sitemap :html-head ,z-shared-head) ("org-tags" :base-directory "~/master-folder/org_files/org_web/tags" :publishing-directory "~/master-folder/org_files/org_web/output/tags" :recursive t :base-extension "org" :publishing-function org-html-publish-to-html :with-author nil :with-creator nil :html-preamble ,z-preamble :html-postamble ,z-postamble :html-head ,z-shared-head) ("org-assets" :base-directory "~/master-folder/org_files/org_web/assets/" :base-extension "css\\|js\\|png\\|jpg\\|gif\\|svg\\|pdf\\|woff\\|woff2\\|ttf" :publishing-directory "~/master-folder/org_files/org_web/output/assets/" :recursive t :publishing-function org-publish-attachment) )) (delete-directory "~/master-folder/org_files/org_web/output/" t) (delete-directory "~/master-folder/org_files/org_web/tags/" t) (message "Directory deleted") ;; Generate the site output (z/write-tag-pages) (org-publish-all t) (message "Build complete!") ;;; build-site.el ends here #+END_SRC * Publish Script As the project is hosted on Github, I have created a small script that is able to push changes to the remote repository, which Cloudflare will automatically detect and rebuild the website: #+BEGIN_SRC bash #!/bin/bash # Exit immediately if any command fails set -e # Define paths ORG_PROJECT_NAME="website" ORG_OUTPUT_DIR="$HOME/master-folder/org_files/org_web/output" # commands used prior to having the `build-script.el` # echo "Running org-publish..." # emacs --batch \ # --eval "(require 'org)" \ # --eval "(require 'ox-publish)" \ # --eval "(org-publish \"$ORG_PROJECT_NAME\" t)" cd "$ORG_OUTPUT_DIR" echo "HTML published to: $ORG_OUTPUT_DIR" # Git commands echo "Adding changes to Git..." git add . echo "Committing..." git commit -m "Auto-publish on $(date)" || echo "Nothing to commit." echo "Pushing to GitHub..." git push origin main echo "Done! Changes pushed and Cloudflare should rebuild the site." #+END_SRC