;;; 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 ;; ;; Optional env vars: ;; SITE_FORCE=1 Force republish all files (default: incremental) ;; SITE_DRY=1 Parse and prepare only; skip org-publish-all ;;; Code: ;; ───────────────────────────────────────────────────────────────────────────── ;; 0. Bootstrap: paths and load-path ;; ───────────────────────────────────────────────────────────────────────────── (defconst z/build-start-time (float-time) "Wall-clock time when the build started.") (defconst z/site-root (file-name-as-directory (file-name-directory (or load-file-name buffer-file-name))) "Absolute path to the site root (always ends with /). This is the single authoritative definition — lisp/* files must NOT redefine site-root themselves.") ;; Keep the old name around so any stray code still compiles. (defvaralias 'site-root 'z/site-root) (setq create-lockfiles nil) (defun site-path (path) "Expand PATH relative to `z/site-root'." (expand-file-name path z/site-root)) (defconst z/output-root (file-name-as-directory (expand-file-name (or (getenv "SITE_OUTPUT_DIR") (site-path "output/")))) "Absolute path to the site output directory. Defaults to output/ under `z/site-root', but CI can set SITE_OUTPUT_DIR to publish into a stable directory instead of the runner's transient checkout.") (defun output-path (path) "Expand PATH relative to `z/output-root'." (expand-file-name path z/output-root)) (add-to-list 'load-path (site-path "lisp")) ;; ───────────────────────────────────────────────────────────────────────────── ;; 1. Logging helpers (ANSI colours for terminal output) ;; ───────────────────────────────────────────────────────────────────────────── (defconst z/colour-reset "\033[0m") (defconst z/colour-bold "\033[1m") (defconst z/colour-dim "\033[2m") (defconst z/colour-green "\033[32m") (defconst z/colour-yellow "\033[33m") (defconst z/colour-cyan "\033[36m") (defconst z/colour-red "\033[31m") (defconst z/colour-blue "\033[34m") (defconst z/colour-magenta "\033[35m") (defun z/log (level fmt &rest args) "Emit a coloured build-log line. LEVEL is one of: info warn error step metric done. FMT / ARGS are passed to `format'." (let* ((msg (apply #'format fmt args)) (prefix (pcase level ('info (concat z/colour-cyan " INFO " z/colour-reset)) ('warn (concat z/colour-yellow " WARN " z/colour-reset)) ('error (concat z/colour-red " ERROR " z/colour-reset)) ('step (concat z/colour-blue z/colour-bold " ──── " z/colour-reset)) ('metric (concat z/colour-magenta " ◈ " z/colour-reset)) ('done (concat z/colour-green z/colour-bold " ✓ " z/colour-reset)) (_ " ")))) (message "%s%s" prefix msg))) (defun z/log-separator (&optional label) "Print a coloured separator line, optionally with LABEL." (if label (message "%s%s── %s %s%s" z/colour-dim z/colour-bold label (make-string (max 0 (- 60 (length label))) ?─) z/colour-reset) (message "%s%s%s" z/colour-dim (make-string 66 ?─) z/colour-reset))) (defmacro z/with-timing (label &rest body) "Execute BODY, then log elapsed seconds under LABEL." (declare (indent 1)) (let ((t0 (gensym "t0"))) `(let ((,t0 (float-time))) (prog1 (progn ,@body) (z/log 'metric "%s took %.2fs" ,label (- (float-time) ,t0)))))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 2. Package bootstrap ;; ───────────────────────────────────────────────────────────────────────────── (z/log-separator "Package setup") (require 'package) (setq package-user-dir (site-path ".packages")) (setq package-archives '(("melpa" . "https://melpa.org/packages/") ("elpa" . "https://elpa.gnu.org/packages/"))) (package-initialize) (z/with-timing "Package archive refresh" (unless package-archive-contents (z/log 'info "Refreshing package archive contents…") (condition-case err (package-refresh-contents) (error (z/log 'warn "Could not refresh archives: %s" (error-message-string err)))))) (z/with-timing "htmlize install" (unless (package-installed-p 'htmlize) (z/log 'info "Installing htmlize…") (condition-case err (package-install 'htmlize) (error (z/log 'error "htmlize install failed: %s" (error-message-string err)) (error "Cannot continue without htmlize"))))) (require 'htmlize) ;; ───────────────────────────────────────────────────────────────────────────── ;; 3. Org / ox requires ;; ───────────────────────────────────────────────────────────────────────────── (require 'ox-publish) (require 'cl-lib) (require 'org) (require 'ox) (require 'ox-html) (require 'ox-md) (setq org-publish-timestamp-directory (site-path ".org-timestamps/")) ;; ───────────────────────────────────────────────────────────────────────────── ;; 4. Local library requires (after load-path is set) ;; ───────────────────────────────────────────────────────────────────────────── (z/log-separator "Local libraries") (dolist (lib '(recently-updated tags sidenotes sitemaps)) (condition-case err (progn (require lib) (z/log 'done "Loaded %s" lib)) (error (z/log 'error "Failed to load %s: %s" lib (error-message-string err)) (error "Aborting: required library %s missing" lib)))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 5. HTML export settings ;; ───────────────────────────────────────────────────────────────────────────── (setq org-html-htmlize-output-type 'css) (defun z/asset-version (path) "Return a cache-busting version string for asset PATH under `z/site-root'." (let* ((file (site-path path)) (attrs (and (file-exists-p file) (file-attributes file))) (mtime (and attrs (file-attribute-modification-time attrs))) (size (and attrs (file-attribute-size attrs)))) (if (and mtime size) (format "%x-%x" (floor (float-time mtime)) size) (format "%x" (floor z/build-start-time))))) (defun z/asset-url (path) "Return root-relative asset PATH with a deterministic cache-busting query." (format "/%s?v=%s" path (z/asset-version path))) (defvar z/shared-head (concat "\n" (mapconcat (lambda (f) (format "" (z/asset-url (format "assets/styles/%s" f)))) '("style.css" "bigger-picture.min.css" "comments.css" "kanban.css" "org-syntax.css" "misc.css" "media.css" "wird-tracker.css" "zhd.css" "play.css" ) "\n") "\n" (mapconcat (lambda (f) (format "" (z/asset-url (format "assets/scripts/%s" f)))) '("script.js" "lunr.js" "competency-status-board.js" "notes.js" "comments.js" "bigger-picture.min.js" "search.js" "svg-pan-zoom.min.js" "gallery-init.js" "sitemap-interactive.js" "wird-tracker.js" "zhd.js" "play.js" ) "\n") (format " " (z/asset-url "assets/scripts/mermaid.min.js")) ) "Shared HTML fragment injected into every page.") ;; ───────────────────────────────────────────────────────────────────────────── ;; 6. Global Org macros ;; ───────────────────────────────────────────────────────────────────────────── (setq org-export-global-macros (append org-export-global-macros '(("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:@@")))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 7. Preamble / postamble ;; ───────────────────────────────────────────────────────────────────────────── (defvar z/preamble "
\"\" zxh
Updated: %C
") (defvar z/postamble "") ;; ───────────────────────────────────────────────────────────────────────────── ;; 8. Comments support ;; ───────────────────────────────────────────────────────────────────────────── (defun z/comments-file-p (file) "Return non-nil if FILE has #+COMMENTS: t." (when (file-exists-p file) (with-temp-buffer (insert-file-contents file) (org-mode) (let* ((keywords (org-collect-keywords '("COMMENTS"))) (val (cadr (assoc "COMMENTS" keywords)))) (and val (string-match-p "^\\s-*t\\s-*$" (downcase val))))))) (defun z/comments-file-slug (file) "Return page slug from #+SLUG: or fallback to filename base." (with-temp-buffer (insert-file-contents file) (org-mode) (let* ((keywords (org-collect-keywords '("SLUG"))) (slug (cadr (assoc "SLUG" keywords)))) (if (and slug (string-match-p "\\S-" slug)) slug (file-name-base file))))) (defun z/org-html-insert-comments-into-body (body backend info) "Append a comments section to BODY for files that opt in." (when (org-export-derived-backend-p backend 'html) (let ((input-file (plist-get info :input-file))) (if (and input-file (z/comments-file-p input-file)) (let* ((slug (z/comments-file-slug input-file)) (html (format "\n

Comments

\n" slug))) (concat body html)) body)))) ;; 8.5 mermaid js support: (defun z/org-html-mermaid-block (code _lang) "Export a mermaid src block as a mermaid div." (format "
\n%s\n
" code)) ;; Hook into ox-html's src block export (defun z/org-html-src-block-mermaid (orig-fun src-block contents info) (let ((lang (org-element-property :language src-block))) (if (string= lang "mermaid") (let ((code (org-remove-indentation (org-element-property :value src-block)))) (format "
\n%s\n
" code)) (funcall orig-fun src-block contents info)))) (advice-add 'org-html-src-block :around #'z/org-html-src-block-mermaid) ;; ───────────────────────────────────────────────────────────────────────────── ;; 9. Body-class injection (WIP / no-sidenotes) ;; ───────────────────────────────────────────────────────────────────────────── (defun z/org-html-add-body-classes (output backend info) "Add layout-related classes to based on file metadata." (when (org-export-derived-backend-p backend 'html) (let* ((input-file (plist-get info :input-file)) (classes (delq nil (list (when (and input-file (z/wip-file-p input-file)) "wip") (when (and input-file (z/no-sidenotes-file-p input-file)) "no-sidenotes"))))) (if classes (replace-regexp-in-string "]*\\)>" (format "" (string-join classes " ")) output) output)))) (add-to-list 'org-export-filter-final-output-functions #'z/org-html-add-body-classes) (add-to-list 'org-export-filter-body-functions #'z/org-html-insert-comments-into-body) ;; ───────────────────────────────────────────────────────────────────────────── ;; 10. Custom z-html backend ;; ───────────────────────────────────────────────────────────────────────────── (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)) ;; ───────────────────────────────────────────────────────────────────────────── ;; 11. Lima project helpers ;; ───────────────────────────────────────────────────────────────────────────── (defun z/lima-sitemap-format-entry (entry style project) "Format a sitemap entry for Lima; skip directories." (let* ((file (if (listp entry) (car entry) entry)) (base-dir (file-name-as-directory (org-publish-property :base-directory project))) (abs (if (file-name-absolute-p file) file (expand-file-name file base-dir)))) (unless (file-directory-p abs) (let* ((rel (file-relative-name abs base-dir)) (rel-noext (file-name-sans-extension rel)) (title (org-publish-find-title abs project))) (format "[[file:%s.html][%s]]" rel-noext (or title (file-name-nondirectory rel-noext))))))) (defun z/copy-neighbor-attachments (source-dir pub-dir) "Copy sibling .attachments.* directories from SOURCE-DIR into PUB-DIR." (dolist (d (directory-files source-dir t "^\\.attachments\\..+")) (when (file-directory-p d) (let ((target (expand-file-name (file-name-nondirectory d) pub-dir))) (make-directory target t) (copy-directory d target t t t))))) (defun z/markdown-h1-title (filename fallback) "Return the first Markdown H1 title in FILENAME, or FALLBACK." (with-temp-buffer (insert-file-contents filename nil 0 4096) (goto-char (point-min)) (if (re-search-forward "^#+ +\\(.+?\\)\\s-*$" nil t) (match-string 1) fallback))) (defun z/publish-lima-file (plist filename pub-dir) "Publish an Org or Markdown file from the lima directory." (let* ((ext (downcase (or (file-name-extension filename) ""))) (base (file-name-base filename)) (src-dir (file-name-directory filename))) (cond ;; ── Org files ────────────────────────────────────────────────────────── ((string= ext "org") (z/copy-neighbor-attachments src-dir pub-dir) (org-publish-org-to 'z-html filename ".html" plist pub-dir)) ;; ── Markdown files ───────────────────────────────────────────────────── ((string= ext "md") (let* ((tmp-dir (make-temp-file "lima-build-" t)) (temp-org (expand-file-name (concat base ".org") tmp-dir))) ;; Markdown → Org (let ((rc (call-process "pandoc" nil nil nil filename "-f" "markdown" "-t" "org" "-o" temp-org))) (unless (zerop rc) (z/log 'error "pandoc failed (rc=%d) for %s" rc filename) (error "pandoc conversion failed for %s" filename))) ;; Prepend front matter (with-temp-buffer (insert-file-contents temp-org) (goto-char (point-min)) (insert (format "#+TITLE: %s\n#+OPTIONS: num:nil\n#+DATE: %s\n#+COMMENTS: t\n#+SLUG: %s\n\n" (z/markdown-h1-title filename base) (format-time-string "<%Y-%m-%d %a %H:%M>") base)) (write-region (point-min) (point-max) temp-org)) ;; Publish (z/copy-neighbor-attachments src-dir pub-dir) (let ((output-file (expand-file-name (concat base ".html") pub-dir))) (org-publish-org-to 'z-html temp-org ".html" plist pub-dir) (let ((generated (expand-file-name (concat (file-name-base temp-org) ".html") pub-dir))) (when (and (file-exists-p generated) (not (string-equal generated output-file))) (rename-file generated output-file t)))))) ;; ── Fallback: treat as attachment ────────────────────────────────────── (t (org-publish-attachment plist filename pub-dir))))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 12. Preparation hooks ;; ───────────────────────────────────────────────────────────────────────────── (defun z/org-main-prep (_project) "Pre-publish hook: regenerate recently-updated.org." (z/log 'step "Generating recently-updated.org…") (z/with-timing "recently-updated generation" (z/generate-recently-updated-org 26))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 13. Publishing project alist ;; ───────────────────────────────────────────────────────────────────────────── (setq org-publish-project-alist `(("org-main" :recursive t :base-directory ,(site-path "") :publishing-function z/z-publish-to-html :publishing-directory ,(output-path "") :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 :preparation-function z/org-main-prep) ("org-assets" :base-directory ,(site-path "assets/") :base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|woff\\|woff2\\|ttf" :publishing-directory ,(output-path "assets/") :recursive t :publishing-function org-publish-attachment) ("org-categories-sitemap" :recursive t :base-directory ,(site-path "home/") :publishing-directory ,(output-path "home/") :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 ,(site-path "posts/") :publishing-directory ,(output-path "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 ,(site-path "blogs/") :publishing-directory ,(output-path "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-grouped-sitemap :sitemap-sort-files anti-chronologically :html-head ,z/shared-head) ("org-books" :base-directory ,(site-path "books/") :publishing-directory ,(output-path "books/") :recursive t :base-extension "org" :publishing-function z/z-publish-to-html :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-career" :base-directory ,(site-path "posts/career/") :publishing-directory ,(output-path "posts/career/") :recursive t :base-extension "org" :publishing-function z/z-publish-to-html :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) ("wip-pages" :base-directory ,(site-path "") :publishing-directory ,(output-path "") :recursive t :base-extension "org" :publishing-function z/z-publish-to-html :html-preamble ,z/preamble :html-postamble ,z/postamble :auto-sitemap t :sitemap-filename "wip.org" :sitemap-title "Work in progress" :sitemap-style list :sitemap-function z/wip-sitemap :sitemap-sort-files anti-chronologically :html-head ,z/shared-head) ("org-tags" :base-directory ,(site-path "tags/") :publishing-directory ,(output-path "tags/") :recursive t :base-extension "org" :publishing-function org-html-publish-to-html :html-preamble ,z/preamble :html-postamble ,z/postamble :html-head ,z/shared-head) ("org-lima" :base-directory ,(site-path "lima/") :publishing-directory ,(output-path "lima/") :recursive t :base-extension "org\\|md" :publishing-function z/publish-lima-file :with-author nil :with-creator nil :html-validation-link nil :html-preamble ,z/preamble :html-postamble ,z/postamble :sitemap-filename "lima-list.org" :auto-sitemap t :sitemap-title "Lima" :sitemap-style list :sitemap-sort-files anti-chronologically :sitemap-format-entry z/lima-sitemap-format-entry :html-head ,z/shared-head))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 14. Pre-build clean-up (opt-in via SITE_CLEAN=1) ;; ───────────────────────────────────────────────────────────────────────────── ;; ;; IMPORTANT: output/ and .org-timestamps/ are a matched pair. ;; .org-timestamps/ is org-publish's cache of every file's last-published ;; mtime. If output/ is wiped without also wiping .org-timestamps/, the next ;; incremental build sees "cache says up-to-date" but the output file is gone, ;; so it silently skips the file. That is why assets/ and index.html vanished. ;; ;; Rule: whenever output/ is deleted, .org-timestamps/ must be deleted too. (z/log-separator "Pre-build") (z/log 'info "Site source root : %s" z/site-root) (z/log 'info "Site output root : %s" z/output-root) (defun z/wipe-build-dirs () "Delete output/ and .org-timestamps/ together." (dolist (dir (list z/output-root (site-path ".org-timestamps/"))) (if (file-directory-p dir) (progn (z/with-timing (format "delete %s" (file-name-nondirectory (directory-file-name dir))) (delete-directory dir t)) (z/log 'done "Deleted %s" dir)) (z/log 'info "Already absent: %s" dir)))) (defun z/output-integrity-ok-p () "Return t if critical output paths all exist. Returns nil (and logs warnings) if assets/ or any top-level HTML is missing. A nil result means the timestamp cache is stale and we must force-republish." (let ((missing '())) (unless (file-directory-p (output-path "assets/")) (push "output/assets/" missing)) (unless (or (file-exists-p (output-path "index.html")) (file-exists-p (output-path "sitemap.html"))) (push "output/index.html (or sitemap.html)" missing)) (dolist (m missing) (z/log 'warn "Integrity check: missing %s" m)) (null missing))) (cond ;; ── Explicit clean requested ─────────────────────────────────────────────── ((getenv "SITE_CLEAN") (z/log 'warn "SITE_CLEAN=1 — wiping output/ and .org-timestamps/") (z/wipe-build-dirs) (setenv "SITE_FORCE" "1")) ;; ── Output looks incomplete — cache is stale ────────────────────────────── ;; Catches the case where output/ was wiped externally (git clean, manual rm, ;; Syncthing conflict) without clearing .org-timestamps/. Without this guard ;; org-publish would silently skip every "up-to-date" file and produce a ;; broken site with missing assets and pages. ((not (z/output-integrity-ok-p)) (z/log 'warn "Output integrity check failed — timestamp cache is stale") (z/log 'warn "Clearing .org-timestamps/ and forcing full republish") (let ((ts-dir (site-path ".org-timestamps/"))) (when (file-directory-p ts-dir) (z/with-timing "delete .org-timestamps" (delete-directory ts-dir t)) (z/log 'done "Deleted .org-timestamps/"))) (setenv "SITE_FORCE" "1")) ;; ── Normal incremental build ────────────────────────────────────────────── (t (z/log 'info "Incremental build — output/ looks complete"))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 15. Tag pages ;; ───────────────────────────────────────────────────────────────────────────── (z/log-separator "Tag generation") (z/with-timing "tag page generation" (condition-case err (let ((n (z/write-tag-pages))) (z/log 'done "Tag pages written%s" (if (numberp n) (format " (%d tags)" n) ""))) (error (z/log 'error "Tag generation failed: %s" (error-message-string err)) (error "Aborting build: tag generation error")))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 16. Publish ;; ───────────────────────────────────────────────────────────────────────────── (z/log-separator "Publishing") (defun z/sync-assets-to-output () "Copy assets/ into the configured output assets directory, overwriting stale files." (let ((source-root (site-path "assets/")) (target-root (output-path "assets/"))) (when (file-directory-p source-root) (dolist (file (directory-files-recursively source-root ".*" nil)) (unless (file-directory-p file) (let* ((rel (file-relative-name file source-root)) (target (expand-file-name rel target-root))) (make-directory (file-name-directory target) t) (copy-file file target t t t t))))))) (if (getenv "SITE_DRY") (z/log 'warn "SITE_DRY=1 — skipping org-publish-all (dry run)") (let ((force (not (not (getenv "SITE_FORCE"))))) (when force (z/log 'info "SITE_FORCE=1 — forcing republish of all files")) (condition-case err (progn (z/with-timing "org-publish-all" (org-publish-all force)) (z/with-timing "asset sync" (z/sync-assets-to-output))) (error (z/log 'error "org-publish-all failed: %s" (error-message-string err)) (error "Build failed"))))) ;; ───────────────────────────────────────────────────────────────────────────── ;; 17. Build summary ;; ───────────────────────────────────────────────────────────────────────────── (z/log-separator "Build summary") (let* ((elapsed (- (float-time) z/build-start-time)) (out-dir z/output-root) (html-files (when (file-directory-p out-dir) (length (directory-files-recursively out-dir "\\.html\\'")))) (css-files (when (file-directory-p out-dir) (length (directory-files-recursively out-dir "\\.css\\'")))) (js-files (when (file-directory-p out-dir) (length (directory-files-recursively out-dir "\\.js\\'")))) (tag-files (let ((td (output-path "tags/"))) (when (file-directory-p td) (length (directory-files td nil "\\.html\\'")))))) (z/log 'metric "Total elapsed : %.2fs" elapsed) (z/log 'metric "HTML files output: %d" (or html-files 0)) (z/log 'metric "CSS files output: %d" (or css-files 0)) (z/log 'metric "JS files output: %d" (or js-files 0)) (z/log 'metric "Tag pages output: %d" (or tag-files 0)) (z/log 'done "Build complete ✓")) ;;; build-site.el ends here