diff --git a/Makefile b/Makefile index d071fa7..44e01dc 100755 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean norm test author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help +.PHONY: all clean norm test test-elisp author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help VENV := .venv PY := $(VENV)/bin/python @@ -25,6 +25,9 @@ search: $(VENV) test: $(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test +test-elisp: + emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit + $(VENV): @echo "Generating virtual environment" python3 -m venv $(VENV) @@ -80,6 +83,7 @@ help: @echo " make clean-venv - Remove venv directory" @echo " make norm - Move backup files" @echo " make test - Run authoring server tests" + @echo " make test-elisp - Run Emacs Lisp build tests" @echo " make author - Install and start the authoring UI service" @echo " make author-install - Install and enable the authoring UI service" @echo " make author-stop - Stop the authoring UI service" diff --git a/build-site.el b/build-site.el index 1ff31e8..6afd916 100755 --- a/build-site.el +++ b/build-site.el @@ -1,23 +1,15 @@ ;;; 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: +;; Run 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 +;; SITE_CLEAN=1 Wipe output/ and .org-timestamps/ before publishing ;;; Code: -;; ───────────────────────────────────────────────────────────────────────────── -;; 0. Bootstrap: paths and load-path -;; ───────────────────────────────────────────────────────────────────────────── - (defconst z/build-start-time (float-time) "Wall-clock time when the build started.") @@ -25,11 +17,8 @@ (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.") + "Absolute path to the site root.") -;; Keep the old name around so any stray code still compiles. (defvaralias 'site-root 'z/site-root) (setq create-lockfiles nil) @@ -43,9 +32,7 @@ redefine site-root themselves.") (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.") + "Absolute path to the site output directory.") (defun output-path (path) "Expand PATH relative to `z/output-root'." @@ -53,789 +40,24 @@ publish into a stable directory instead of the runner's transient checkout.") (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 'json) -(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" - "hidden-details.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" - "ash-below-lake.js" - "hidden-details.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/comment-page-url (file) - "Return root-relative published HTML URL for source FILE." - (concat "/" - (file-name-sans-extension - (file-relative-name file z/site-root)) - ".html")) - -(defun z/comment-page-title (file) - "Return exported page title for FILE, or the file basename." - (with-temp-buffer - (insert-file-contents file nil 0 4096) - (goto-char (point-min)) - (if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+?\\)[ \t]*$" nil t) - (match-string 1) - (file-name-base file)))) - -(defun z/comment-page-record (file) - "Return a JSON-ready comment page record for FILE." - `((slug . ,(z/comments-file-slug file)) - (title . ,(z/comment-page-title file)) - (url . ,(z/comment-page-url file)))) - -(defun z/generate-comment-pages-json () - "Write a slug-to-page manifest for comment-enabled Org files." - (let* ((files (directory-files-recursively z/site-root "\\.org\\'")) - (records - (cl-loop for file in files - unless (or (string-prefix-p (site-path "output/") file) - (string-prefix-p (site-path "backups/") file) - (string-prefix-p (site-path ".org-timestamps/") file) - (string-prefix-p (site-path ".packages/") file)) - when (z/comments-file-p file) - collect (z/comment-page-record file))) - (target (site-path "assets/content/comment-pages.json")) - (json-encoding-pretty-print t)) - (make-directory (file-name-directory target) t) - (with-temp-file target - (insert (json-encode (vconcat records))) - (insert "\n")) - (z/log 'done "Wrote comment page manifest (%d pages)" (length records)))) - -(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-function z/main-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-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")))) - -(z/with-timing "comment page manifest" - (condition-case err - (z/generate-comment-pages-json) - (error - (z/log 'error "Comment page manifest failed: %s" (error-message-string err)) - (error "Aborting build: comment page manifest 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 ✓")) +(require 'build-core) + +(z/bootstrap-packages) +(z/configure-org) + +(dolist (library '(recently-updated + tags + sidenotes + sitemaps + build-assets + build-comments + build-html + build-lima + build-projects + build-runner)) + (z/require-local library)) + +(z/configure-publish-projects) +(z/run-build) ;;; build-site.el ends here diff --git a/home/guide/setup.org b/home/guide/setup.org index 8fe8be3..39c3d7b 100755 --- a/home/guide/setup.org +++ b/home/guide/setup.org @@ -197,9 +197,6 @@ The script is as follows, at the start I have some metadata relating to the 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"))))) @@ -339,25 +336,6 @@ The script is as follows, at the start I have some metadata relating to the file :sitemap-sort-files anti-chronologically :html-head ,z-shared-head ) - ("wip-pages" - :base-directory "~/master-folder/org-platform/org_web/" - :publishing-directory "~/master-folder/org-platform/org_web/output/" - :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 "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 "~/master-folder/org-platform/org_web/tags" :publishing-directory "~/master-folder/org-platform/org_web/output/tags" diff --git a/lisp/build-assets.el b/lisp/build-assets.el new file mode 100644 index 0000000..c0521c2 --- /dev/null +++ b/lisp/build-assets.el @@ -0,0 +1,78 @@ +;;; build-assets.el --- Asset URLs and publishing helpers -*- lexical-binding: t; -*- + +;;; Code: + +(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" + "hidden-details.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" + "ash-below-lake.js" + "hidden-details.js") + "\n") + (format "" + (z/asset-url "assets/scripts/mermaid.min.js"))) + "Shared HTML fragment injected into every page.") + +(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))))))) + +(provide 'build-assets) + +;;; build-assets.el ends here diff --git a/lisp/build-comments.el b/lisp/build-comments.el new file mode 100644 index 0000000..8912622 --- /dev/null +++ b/lisp/build-comments.el @@ -0,0 +1,103 @@ +;;; build-comments.el --- Comment manifest and export filter -*- lexical-binding: t; -*- + +;;; Code: + +(require 'cl-lib) +(require 'json) +(require 'org) + +(defun z/comments-file-p (file) + "Return non-nil if FILE has #+COMMENTS: t." + (when (and file (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/comment-page-url (file) + "Return root-relative published HTML URL for source FILE." + (concat "/" + (file-name-sans-extension + (file-relative-name file z/site-root)) + ".html")) + +(defun z/comment-page-title (file) + "Return exported page title for FILE, or the file basename." + (with-temp-buffer + (insert-file-contents file nil 0 4096) + (goto-char (point-min)) + (if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+?\\)[ \t]*$" nil t) + (match-string 1) + (file-name-base file)))) + +(defun z/comment-page-record (file) + "Return a JSON-ready comment page record for FILE." + `((slug . ,(z/comments-file-slug file)) + (title . ,(z/comment-page-title file)) + (url . ,(z/comment-page-url file)))) + +(defun z/generate-comment-pages-json () + "Write a slug-to-page manifest for comment-enabled Org files." + (let* ((files (directory-files-recursively z/site-root "\\.org\\'")) + (records + (cl-loop for file in files + unless (or (string-prefix-p (site-path "output/") file) + (string-prefix-p (site-path "backups/") file) + (string-prefix-p (site-path ".org-timestamps/") file) + (string-prefix-p (site-path ".packages/") file)) + when (z/comments-file-p file) + collect (z/comment-page-record file))) + (target (site-path "assets/content/comment-pages.json")) + (json-encoding-pretty-print t)) + (make-directory (file-name-directory target) t) + (with-temp-file target + (insert (json-encode (vconcat records))) + (insert "\n")) + (z/log 'done "Wrote comment page manifest (%d pages)" (length records)))) + +(defun z/org-html-insert-comments-into-body (body backend info) + "Append a comments section to BODY for files that opt in." + (if (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)) + body)) + +(provide 'build-comments) + +;;; build-comments.el ends here diff --git a/lisp/build-core.el b/lisp/build-core.el new file mode 100644 index 0000000..3892b81 --- /dev/null +++ b/lisp/build-core.el @@ -0,0 +1,102 @@ +;;; build-core.el --- Build bootstrap and logging -*- lexical-binding: t; -*- + +;;; Code: + +(require 'package) + +(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 and 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 " OK " z/colour-reset)) + (_ " ")))) + (message "%s%s" prefix msg))) + +(defun z/log-separator (&optional label) + "Print a 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)))))) + +(defun z/bootstrap-packages () + "Set up Emacs packages required by the build." + (z/log-separator "Package setup") + (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)) + +(defun z/configure-org () + "Load Org export libraries and configure shared Org settings." + (require 'ox-publish) + (require 'cl-lib) + (require 'json) + (require 'org) + (require 'ox) + (require 'ox-html) + (require 'ox-md) + (setq org-publish-timestamp-directory (site-path ".org-timestamps/")) + (setq org-html-htmlize-output-type 'css)) + +(defun z/require-local (library) + "Require local build LIBRARY with consistent error reporting." + (condition-case err + (progn + (require library) + (z/log 'done "Loaded %s" library)) + (error + (z/log 'error "Failed to load %s: %s" library (error-message-string err)) + (error "Aborting: required library %s missing" library)))) + +(provide 'build-core) + +;;; build-core.el ends here diff --git a/lisp/build-html.el b/lisp/build-html.el new file mode 100644 index 0000000..cad9367 --- /dev/null +++ b/lisp/build-html.el @@ -0,0 +1,113 @@ +;;; build-html.el --- HTML export customisation -*- lexical-binding: t; -*- + +;;; Code: + +(require 'org) +(require 'ox-html) +(require 'subr-x) +(require 'tags) +(require 'sidenotes) +(require 'build-comments) + +(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:@@")))) + +(defvar z/preamble + "
+ + \"\" + zxh + + +
+ + + +
+
+
Updated: %C
+") + +(defvar z/postamble + "
+
+
+Copyright © 2022-2026 Zaine Qayyum. All rights reserved unless otherwise noted.
+
+Created with %c on Arch \ +GNU/Linux +
+
") + +(defun z/org-html-src-block-mermaid (orig-fun src-block contents info) + "Export Mermaid source blocks as Mermaid divs." + (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) + +(defun z/org-html-add-body-classes (output backend info) + "Add layout-related classes to based on file metadata." + (if (org-export-derived-backend-p backend 'html) + (let* ((input-file (plist-get info :input-file)) + (classes + (delq nil + (list + (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)) + 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) + +(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)) + +(provide 'build-html) + +;;; build-html.el ends here diff --git a/lisp/build-lima.el b/lisp/build-lima.el new file mode 100644 index 0000000..0acd182 --- /dev/null +++ b/lisp/build-lima.el @@ -0,0 +1,89 @@ +;;; build-lima.el --- Lima publishing adapter -*- lexical-binding: t; -*- + +;;; Code: + +(require 'org) +(require 'ox-publish) + +(defun z/lima-entry-title (file fallback) + "Return FILE's Org title, or FALLBACK when no title is present." + (with-temp-buffer + (insert-file-contents file nil 0 4096) + (goto-char (point-min)) + (if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+?\\)[ \t]*$" nil t) + (match-string 1) + fallback))) + +(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 (z/lima-entry-title abs (file-name-nondirectory rel-noext)))) + (format "[[file:%s.html][%s]]" + rel-noext + title))))) + +(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 + ((string= ext "org") + (z/copy-neighbor-attachments src-dir pub-dir) + (org-publish-org-to 'z-html filename ".html" plist pub-dir)) + ((string= ext "md") + (let* ((tmp-dir (make-temp-file "lima-build-" t)) + (temp-org (expand-file-name (concat base ".org") tmp-dir))) + (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))) + (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)) + (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)))))) + (t + (org-publish-attachment plist filename pub-dir))))) + +(provide 'build-lima) + +;;; build-lima.el ends here diff --git a/lisp/build-projects.el b/lisp/build-projects.el new file mode 100644 index 0000000..25df9cb --- /dev/null +++ b/lisp/build-projects.el @@ -0,0 +1,139 @@ +;;; build-projects.el --- org-publish project configuration -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ox-publish) + +(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))) + +(defun z/configure-publish-projects () + "Install `org-publish-project-alist' for the site." + (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-function z/main-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-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) + + ("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)))) + +(provide 'build-projects) + +;;; build-projects.el ends here diff --git a/lisp/build-runner.el b/lisp/build-runner.el new file mode 100644 index 0000000..7ee0fdc --- /dev/null +++ b/lisp/build-runner.el @@ -0,0 +1,120 @@ +;;; build-runner.el --- Build orchestration -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ox-publish) + +(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." + (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))) + +(defun z/prepare-output-cache () + "Keep org-publish output and timestamp cache in sync." + (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) + (cond + ((getenv "SITE_CLEAN") + (z/log 'warn "SITE_CLEAN=1 - wiping output/ and .org-timestamps/") + (z/wipe-build-dirs) + (setenv "SITE_FORCE" "1")) + ((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")) + (t + (z/log 'info "Incremental build - output/ looks complete")))) + +(defun z/generate-build-inputs () + "Generate source-side data files required before publishing." + (z/log-separator "Generated inputs") + (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")))) + (z/with-timing "comment page manifest" + (condition-case err + (z/generate-comment-pages-json) + (error + (z/log 'error "Comment page manifest failed: %s" (error-message-string err)) + (error "Aborting build: comment page manifest error"))))) + +(defun z/publish-site () + "Publish the configured site." + (z/log-separator "Publishing") + (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")))))) + +(defun z/log-build-summary () + "Log a summary of output artifacts." + (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"))) + +(defun z/run-build () + "Run the full site build." + (z/prepare-output-cache) + (z/generate-build-inputs) + (z/publish-site) + (z/log-build-summary)) + +(provide 'build-runner) + +;;; build-runner.el ends here diff --git a/lisp/recently-updated.el b/lisp/recently-updated.el index 713b729..be13050 100755 --- a/lisp/recently-updated.el +++ b/lisp/recently-updated.el @@ -20,8 +20,7 @@ (defconst z/recent-omit-names '("sitemap.org" "categories.org" - "recently-updated.org" - "wip.org") + "recently-updated.org") "Org files that are always excluded from the recently-updated list.") (defun z/recent-omit-org-p (rel-org) diff --git a/lisp/sitemaps.el b/lisp/sitemaps.el index 98c4f1f..d8eb064 100755 --- a/lisp/sitemaps.el +++ b/lisp/sitemaps.el @@ -351,59 +351,6 @@ CATEGORIES-REL is the relative href to categories.html." "\n") "_No tags found yet._"))))) -;; ───────────────────────────────────────────────────────────────────────────── -;; WIP sitemap -;; ───────────────────────────────────────────────────────────────────────────── - -(defun z/wip-file-p (file) - "Return non-nil if FILE is marked as work-in-progress. - -A file is WIP when any of the following is true: - - It has a #+WIP: keyword with a non-empty value. - - Its FILETAGS contain :WIP:. - - Any top-level heading contains \"WIP\" (case-insensitive)." - (when (file-exists-p file) - (with-temp-buffer - (insert-file-contents file) - (org-mode) - (let* ((case-fold-search t) - (keywords (org-collect-keywords '("WIP" "FILETAGS"))) - (wip (cadr (assoc "WIP" keywords))) - (filetags (cadr (assoc "FILETAGS" keywords)))) - (or - (and wip (string-match-p "\\S-" wip)) - (and filetags (string-match-p ":WIP:" (concat ":" filetags ":"))) - (save-excursion - (goto-char (point-min)) - (re-search-forward "^\\*+ .*WIP.*" nil t))))))) - -(defun z/wip-sitemap (title list) - "Sitemap listing only entries whose source files are marked WIP." - (let ((items - (delq nil - (mapcar - (lambda (entry) - (when (consp entry) - (let* ((link (car entry)) - (filename (if (string-match "\\[\\[file:\\([^]]+\\)\\]" link) - (match-string 1 link) - link)) - (full-path (expand-file-name filename z/site-root))) - (when (z/wip-file-p full-path) - (let* ((date (org-publish-find-date full-path org-publish-project-alist)) - (date-str (if date - (format-time-string "%d-%m-%Y %H:%M" date) - ""))) - (format "- %s @@html:%s@@" - link date-str)))))) - (cdr list))))) - (concat "#+TITLE: " title "\n" - "#+OPTIONS: toc:nil num:nil\n\n" - "* Work in progress\n" - (if items - (mapconcat #'identity items "\n") - "No items currently marked as WIP.\n")))) - (provide 'sitemaps) ;;; sitemaps.el ends here diff --git a/lisp/tags.el b/lisp/tags.el index 8ef7969..36b4520 100755 --- a/lisp/tags.el +++ b/lisp/tags.el @@ -33,7 +33,7 @@ (defconst z/generated-org-names '("posts-list.org" "blogs-list.org" "sitemap.org" "categories.org" - "recently-updated.org" "wip.org") + "recently-updated.org") "Generated Org files that should never be indexed for tags.") (defun z/gather-tag-index () @@ -112,4 +112,4 @@ Returns the number of tag pages written." (provide 'tags) -;;; tags.el ends here \ No newline at end of file +;;; tags.el ends here diff --git a/build-site-tests.el b/lisp/tests/build-site-tests.el old mode 100755 new mode 100644 similarity index 80% rename from build-site-tests.el rename to lisp/tests/build-site-tests.el index 0065442..4175952 --- a/build-site-tests.el +++ b/lisp/tests/build-site-tests.el @@ -1,15 +1,49 @@ -;;; build-site-tests.el --- ERT unit tests for build-site.el -*- lexical-binding: t; -*- +;;; build-site-tests.el --- ERT unit tests for build modules -*- lexical-binding: t; -*- ;; Run with: -;; emacs -Q --batch -l build-site.el -l build-site-tests.el -f ert-run-tests-batch-and-exit +;; emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit ;; Or interactively: -;; M-x load-file RET build-site-tests.el RET +;; M-x load-file RET lisp/tests/build-site-tests.el RET ;; M-x ert RET t RET ;;; Code: +(defconst z/test-root + (file-name-directory + (directory-file-name + (file-name-directory + (directory-file-name + (file-name-directory (or load-file-name buffer-file-name))))))) + +(defconst z/build-start-time (float-time)) +(defconst z/site-root (file-name-as-directory z/test-root)) +(defvaralias 'site-root 'z/site-root) +(defconst z/output-root (file-name-as-directory (expand-file-name "output/" z/site-root))) + +(defun site-path (path) + "Expand PATH relative to test `z/site-root'." + (expand-file-name path z/site-root)) + +(defun output-path (path) + "Expand PATH relative to test `z/output-root'." + (expand-file-name path z/output-root)) + +(add-to-list 'load-path (site-path "lisp")) + (require 'ert) +(require 'cl-lib) (require 'org) +(require 'build-core) + +(z/configure-org) +(require 'recently-updated) +(require 'tags) +(require 'sidenotes) +(require 'sitemaps) +(require 'build-assets) +(require 'build-comments) +(require 'build-html) +(require 'build-lima) ;; ── Helpers ────────────────────────────────────────────────────────────────── @@ -110,15 +144,11 @@ (should (string= "
body
" result))))) (ert-deftest test/insert-comments-ignores-non-html-backends () - "For non-HTML backends the function currently returns nil (known bug: -`when' has no else branch so the body string is not passed through). -Fix: change `when' to `if' with body as the else clause. -This test documents current behaviour; flip the should once fixed." + "For non-HTML backends the body should pass through unchanged." (with-temp-org-file "#+TITLE: Test\n#+COMMENTS: t\n\nBody.\n" (let* ((info (list :input-file temp-file)) (result (z/org-html-insert-comments-into-body "
body
" 'latex info))) - ;; BUG: should be (should (string= "
body
" result)) - (should (null result))))) + (should (string= "
body
" result))))) (ert-deftest test/insert-comments-handles-nil-input-file () "Should return body unchanged when :input-file is nil." @@ -201,62 +231,34 @@ This test documents current behaviour; flip the should once fixed." (delete-directory pub-dir t)))) ;; ── z/org-html-add-body-classes ────────────────────────────────────────────── -;; These tests stub out z/wip-file-p and z/no-sidenotes-file-p so they run -;; without the full site infrastructure loaded. - -(ert-deftest test/add-body-classes-adds-wip-class () - "Should add class=\"wip\" to when z/wip-file-p is t." - (with-temp-org-file "#+TITLE: WIP page\n\nContent.\n" - (cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) t)) - ((symbol-function 'z/no-sidenotes-file-p) (lambda (_) nil))) - (let* ((info (list :input-file temp-file)) - (output "

hello

") - (result (z/org-html-add-body-classes output 'html info))) - (should (string-match-p "class=\"wip\"" result)))))) +;; These tests stub out z/no-sidenotes-file-p so they run without depending on +;; full site metadata. (ert-deftest test/add-body-classes-adds-no-sidenotes-class () "Should add class=\"no-sidenotes\" when z/no-sidenotes-file-p is t." (with-temp-org-file "#+TITLE: No sidenotes\n\nContent.\n" - (cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) nil)) - ((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t))) + (cl-letf (((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t))) (let* ((info (list :input-file temp-file)) (output "

hello

") (result (z/org-html-add-body-classes output 'html info))) (should (string-match-p "class=\"no-sidenotes\"" result)))))) -(ert-deftest test/add-body-classes-combines-multiple-classes () - "Both wip and no-sidenotes classes should appear when both predicates are t." - (with-temp-org-file "#+TITLE: Both\n\nContent.\n" - (cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) t)) - ((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t))) - (let* ((info (list :input-file temp-file)) - (output "

hello

") - (result (z/org-html-add-body-classes output 'html info))) - (should (string-match-p "wip" result)) - (should (string-match-p "no-sidenotes" result)))))) - (ert-deftest test/add-body-classes-unchanged-when-no-classes () "Output should be unmodified when no special classes apply." (with-temp-org-file "#+TITLE: Plain\n\nContent.\n" - (cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) nil)) - ((symbol-function 'z/no-sidenotes-file-p) (lambda (_) nil))) + (cl-letf (((symbol-function 'z/no-sidenotes-file-p) (lambda (_) nil))) (let* ((info (list :input-file temp-file)) (output "

hello

") (result (z/org-html-add-body-classes output 'html info))) (should (string= output result)))))) (ert-deftest test/add-body-classes-ignores-non-html-backend () - "For non-HTML backends the function currently returns nil (known bug: -`when' has no else branch so output is not passed through). -Fix: change `when' to `if' with output as the else clause. -This test documents current behaviour; flip the should once fixed." + "For non-HTML backends the output should pass through unchanged." (with-temp-org-file "#+TITLE: LaTeX\n\nContent.\n" - (cl-letf (((symbol-function 'z/wip-file-p) (lambda (_) t)) - ((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t))) + (cl-letf (((symbol-function 'z/no-sidenotes-file-p) (lambda (_) t))) (let* ((info (list :input-file temp-file)) (output "\\documentclass{article}") (result (z/org-html-add-body-classes output 'latex info))) - ;; BUG: should be (should (string= output result)) - (should (null result)))))) + (should (string= output result)))))) -;;; build-site-tests.el ends here \ No newline at end of file +;;; build-site-tests.el ends here diff --git a/publish-and-push.sh b/publish-and-push.sh deleted file mode 100755 index d4da7f3..0000000 --- a/publish-and-push.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# Exit immediately if any command fails -set -e - -# Define paths -ORG_OUTPUT_DIR="$HOME/master-folder/org-platform/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." diff --git a/sitemap.org b/sitemap.org index dfc0b9a..c603bd0 100755 --- a/sitemap.org +++ b/sitemap.org @@ -8,8 +8,6 @@ flowchart TD root --> n1 n2["Play"] root --> n2 - n3["Work in progress"] - root --> n3 n4["Recently Updated"] root --> n4 n9{{"blogs"}} @@ -156,7 +154,6 @@ flowchart TD n67 --> n79 click n1 "index.html" "Home" click n2 "play.html" "Play" - click n3 "wip.html" "Work in progress" click n4 "recently-updated.html" "Recently Updated" click n10 "blogs/blogs-intro.html" "Blogs Introduction" click n11 "blogs/publish-pages.html" "How to publish pages using Org Publish" @@ -226,7 +223,6 @@ flowchart TD * Pages - [[file:index.org][Home]] - [[file:play.org][Play]] -- [[file:wip.org][Work in progress]] - [[file:recently-updated.org][Recently Updated]] - blogs - [[file:blogs/blogs-intro.org][Blogs Introduction]] diff --git a/wip.org b/wip.org deleted file mode 100755 index deff3ae..0000000 --- a/wip.org +++ /dev/null @@ -1,5 +0,0 @@ -#+TITLE: Work in progress -#+OPTIONS: toc:nil num:nil - -* Work in progress -No items currently marked as WIP.