#+TITLE: Setup #+OPTIONS: toc:nil num:nil #+COMMENTS: t #+SLUG: setup * Introduction /Last updated: <2025-11-06 Thu 21:16>/ See this link[fn:1] for a more generic introduction on how to publish websites using ~org-publish~. 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) (require 'ox) (require 'ox-html) ;; Initialize the package system (package-initialize) (unless package-archive-contents (package-refresh-contents)) ;; Install dependencies (package-install 'htmlize) (add-to-list 'load-path "~/master-folder/org_files/org_web/") (require 'htmlize) (setq org-html-htmlize-output-type 'css) (defvar z-shared-head " " ) (setq org-export-global-macros (append '(("sidenote" . "@@html:$2@@") ("epigraph" . "@@html:
$1
$2
@@") ("epigraph_single" . "@@html:
$1
@@") ("epigraph3" . "@@html:
$1
$2, $3
@@") ("kbd" . "@@html:$1@@@@latex:\\texttt{$1}@@") ("margimg" . "@@html:@@") ("countdown" . "@@html:@@") org-export-global-macros))) (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 "%d-%m-%Y %H:%M" 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 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 "%d-%m-%Y %H:%M" 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 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/books-sitemap (title list) "sitemap that lists books." (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil \n\n" "See the categories: @@html:Categories@@\n\n" "* Book Notes:\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/books/")) (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 "%d-%m-%Y %H:%M" 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 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/2025-sitemap (title list) "Sitemap that lists 2025 posts grouped by month, with dates and FILETAGS." (let ((output (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil \n\n" "See the categories: @@html:Categories@@\n\n" "* 2025\n")) (current-month nil)) ;; `list` is what org-publish passes in; we skip its car (top-level title node) (dolist (entry (cdr list)) ;; In this setup each ENTRY is usually (LINK . OTHER-STUFF) (when (consp entry) (let* ((link (car entry)) ;; Extract relative filename from the [[file:...][...]] 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/2025/")) (date (when (file-exists-p full-path) (org-publish-find-date full-path org-publish-project-alist))) (date-str (if date (format-time-string "%d-%m-%Y %H:%M" date) "no date")) (month-str (if date (format-time-string "%B %Y" date) ; e.g. "August 2025" "No date")) (tags-str "")) ;; Collect FILETAGS from the file (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 tag)) (split-string tags ":" t) " ")))))) ;; Insert month heading whenever month changes (unless (equal month-str current-month) (setq current-month month-str) (setq output (concat output "\n** " month-str "\n"))) ;; Insert the actual entry (setq output (concat output (format "- %s @@html:%s@@ %s\n" link date-str tags-str)))))) output)) (defun z/career-sitemap (title list) "Sitemap that lists careers posts grouped by month, with dates and FILETAGS." (let ((output (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil \n\n" "See the categories: @@html:Categories@@\n\n" "* 2025\n" "See the following page for more details: @@html:Career Intro@@\n" )) (current-month nil)) ;; `list` is what org-publish passes in; we skip its car (top-level title node) (dolist (entry (cdr list)) ;; In this setup each ENTRY is usually (LINK . OTHER-STUFF) (when (consp entry) (let* ((link (car entry)) ;; Extract relative filename from the [[file:...][...]] 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/career/")) (date (when (file-exists-p full-path) (org-publish-find-date full-path org-publish-project-alist))) (date-str (if date (format-time-string "%d-%m-%Y %H:%M" date) "no date")) (month-str (if date (format-time-string "%B %Y" date) ; e.g. "August 2025" "No date")) (tags-str "")) ;; Collect FILETAGS from the file (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 tag)) (split-string tags ":" t) " ")))))) ;; Insert month heading whenever month changes (unless (equal month-str current-month) (setq current-month month-str) (setq output (concat output "\n** " month-str "\n"))) ;; Insert the actual entry (setq output (concat output (format "- %s @@html:%s@@ %s\n" link date-str tags-str)))))) output)) (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) ))) (defun z/filetags-html (info) "Return an HTML snippet for FILETAGS from INFO, or nil if none." (let ((tags (plist-get info :filetags))) (when tags (format "
%s
\n" (mapconcat (lambda (tag) (format " %s " tag)) tags " "))))) (defun z/insert-filetags-after-title (output backend info) "Insert FILETAGS after the first

in OUTPUT." (if (org-export-derived-backend-p backend 'html) (let ((block (z/filetags-html info))) (if (and block (string-match "\\(]*class=[\"']title[\"'][^>]*>.*?

\\)" output)) (replace-match (concat "\\1\n" block) t nil output) output)) output)) (org-export-define-derived-backend 'z-html 'html :filters-alist '((:filter-final-output . z/insert-filetags-after-title))) (defun z/z-publish-to-html (plist filename pub-dir) "Publish FILENAME to HTML using the z-html backend." (org-publish-org-to 'z-html filename ".html" plist pub-dir)) ;; Define the publishing project (setq org-publish-project-alist `(("org-main" :recursive t :base-directory "~/master-folder/org_files/org_web/" :publishing-function z/z-publish-to-html :publishing-directory "~/master-folder/org_files/org_web/output" :base-extension "org" :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 z/z-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 :sitemap-sort-files anti-chronologically :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 z/z-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 :sitemap-sort-files anti-chronologically :html-head ,z-shared-head ) ("org-books" :base-directory "~/master-folder/org_files/org_web/books/" :publishing-directory "~/master-folder/org_files/org_web/output/books/" :recursive t :base-extension "org" :publishing-function z/z-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 "books-list.org" :sitemap-title "Books List" :sitemap-style list :sitemap-function z/books-sitemap :sitemap-sort-files anti-chronologically :html-head ,z-shared-head ) ("org-2025" :base-directory "~/master-folder/org_files/org_web/blogs/2025/" :publishing-directory "~/master-folder/org_files/org_web/output/blogs/2025/" :recursive t :base-extension "org" :publishing-function z/z-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 "2025-list.org" :sitemap-title "2025 List" :sitemap-style list :sitemap-function z/2025-sitemap :sitemap-sort-files anti-chronologically :html-head ,z-shared-head ) ("org-career" :base-directory "~/master-folder/org_files/org_web/posts/career/" :publishing-directory "~/master-folder/org_files/org_web/output/posts/career/" :recursive t :base-extension "org" :publishing-function z/z-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 "career-list.org" :sitemap-title "Career List" :sitemap-style list :sitemap-function z/career-sitemap :sitemap-sort-files anti-chronologically :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") (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_OUTPUT_DIR="$HOME/master-folder/org_files/org_web/output" 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 [fn:1] See: [[~/master-folder/org_files/org_web/blogs/publish-pages.org][this link]]