;;; sitemaps.el --- Sitemap generators for org-publish -*- lexical-binding: t; -*- ;;; Commentary: ;; Custom :sitemap-function implementations for each publishing project. ;; ;; NOTE: Do NOT define `site-root' here. build-site.el owns that definition. ;;; Code: (require 'cl-lib) (require 'org) ;; ───────────────────────────────────────────────────────────────────────────── ;; Internal helpers ;; ───────────────────────────────────────────────────────────────────────────── (defun z/sitemap--tag-html (tags-raw) "Convert a raw FILETAGS string into an Org-mode HTML inline tag list. Returns an empty string when TAGS-RAW is nil or blank." (if (and tags-raw (string-match-p "\\S-" tags-raw)) (mapconcat (lambda (tag) (format "@@html:%s@@" tag tag)) (split-string tags-raw ":" t) " ") "")) (defun z/hidden-page-p (file) "Return non-nil when FILE opts out of public index pages." (and (file-exists-p file) (with-temp-buffer (insert-file-contents file) (org-mode) (let ((value (cadr (assoc "HIDDEN_PAGE" (org-collect-keywords '("HIDDEN_PAGE")))))) (and value (string-match-p "^\\s-*t\\s-*$" (downcase value))))))) (defun z/sitemap--link-file (link base-dir) "Return absolute file path from Org sitemap LINK under BASE-DIR." (when (and (stringp link) (string-match "\\[\\[file:\\([^]]+\\)\\]" link)) (expand-file-name (match-string 1 link) base-dir))) (defun z/sitemap--filter-hidden (node base-dir) "Remove nodes marked with #+HIDDEN_PAGE: t from sitemap NODE." (cond ((not (consp node)) node) ((let ((file (z/sitemap--link-file (car node) base-dir))) (and file (z/hidden-page-p file))) nil) (t (let ((children (delq nil (mapcar (lambda (child) (z/sitemap--filter-hidden child base-dir)) (cdr node))))) (cons (car node) children))))) (defun z/main-sitemap (title list) "Default sitemap with hidden pages removed, plus an automatic Mermaid map." (let ((filtered (z/sitemap--filter-hidden list z/site-root))) (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil\n\n" "#+begin_src mermaid\n" (z/sitemap--mermaid filtered) "\n#+end_src\n\n" "* Pages\n" (replace-regexp-in-string "\\`#\\+TITLE: .*\n+" "" (org-publish-sitemap-default title filtered))))) (defun z/sitemap--mermaid-label (text) "Escape TEXT for a Mermaid quoted label." (replace-regexp-in-string "\"" "\\\\\"" (replace-regexp-in-string "[\n\r\t ]+" " " (string-trim (or text ""))) t t)) (defun z/sitemap--link-data (link) "Return (REL TITLE) from an Org sitemap LINK, or nil for directory nodes." (when (and (stringp link) (string-match "\\[\\[file:\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" link)) (list (match-string 1 link) (match-string 2 link)))) (defun z/sitemap--html-href (rel) "Return published HTML href for Org path REL." (concat (file-name-sans-extension rel) ".html")) (defun z/sitemap--mermaid (tree) "Render sitemap TREE as Mermaid flowchart source." (let ((counter 0) (lines nil) (clicks '())) (cl-labels ((next-id () (setq counter (1+ counter)) (format "n%d" counter)) (node-label (node) (let ((data (z/sitemap--link-data (car node)))) (if data (cadr data) (car node)))) (walk (node parent) (when (consp node) (if (not (stringp (car node))) (dolist (child (cdr node)) (walk child parent)) (let* ((id (next-id)) (data (z/sitemap--link-data (car node))) (label (z/sitemap--mermaid-label (node-label node))) (children (cdr node)) (shape (if data "[\"%s\"]" "{{\"%s\"}}"))) (push (format " %s%s" id (format shape label)) lines) (push (format " %s --> %s" parent id) lines) (when data (push (format " click %s \"%s\" \"%s\"" id (z/sitemap--html-href (car data)) (z/sitemap--mermaid-label (cadr data))) clicks)) (dolist (child children) (walk child id))))))) (dolist (node (if (and (consp tree) (not (stringp (car tree)))) (cdr tree) tree)) (walk node "root"))) (mapconcat #'identity (append '("flowchart TD" " root([\"zxh\"])") (nreverse lines) (nreverse clicks)) "\n"))) (defun z/sitemap--entry-data (link base-dir) "Extract (FULL-PATH DATE-STR TAGS-STR) for a sitemap ENTRY under BASE-DIR. LINK is the raw [[file:...][...]] string produced by org-publish." (let* ((filename (if (string-match "\\[\\[file:\\([^]]+\\)\\]" link) (match-string 1 link) link)) (full-path (expand-file-name filename base-dir)) (date (and (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")) (tags-raw (and (file-exists-p full-path) (with-temp-buffer (insert-file-contents full-path) (org-mode) (cadr (assoc "FILETAGS" (org-collect-keywords '("FILETAGS")))))))) (list full-path date-str (z/sitemap--tag-html tags-raw) date))) (defun z/sitemap--flat-list (title list base-dir section-title categories-rel) "Render a flat bullet-point sitemap. TITLE — org-publish title string LIST — the raw list from org-publish (car is root node) BASE-DIR — absolute path to the project's :base-directory SECTION-TITLE — heading text, e.g. \"Posts\" CATEGORIES-REL — relative path to categories.html from this project root" (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil\n\n" (format "See the categories: @@html:Categories@@\n\n" categories-rel) "* " section-title "\n" (mapconcat (lambda (entry) (when (consp entry) (let* ((link (car entry)) (data (z/sitemap--entry-data link base-dir)) (date-str (nth 1 data)) (tags-str (nth 2 data))) (format "- %s @@html:%s@@ %s" link date-str tags-str)))) (cdr list) "\n"))) ;; ───────────────────────────────────────────────────────────────────────────── ;; Posts ;; ───────────────────────────────────────────────────────────────────────────── (defun z/posts-sitemap (title list) "Flat sitemap for org-posts." (z/sitemap--flat-list title list (site-path "posts/") "Posts:" "../home/categories.html")) ;; ───────────────────────────────────────────────────────────────────────────── ;; Blogs — flat (kept for compatibility, not used as default) ;; ───────────────────────────────────────────────────────────────────────────── (defun z/blogs-sitemap (title list) "Flat sitemap for org-blogs (use z/blogs-grouped-sitemap for the default)." (z/sitemap--flat-list title list (site-path "blogs/") "Blogs:" "../home/categories.html")) ;; ───────────────────────────────────────────────────────────────────────────── ;; Blogs — grouped by year → month ;; ───────────────────────────────────────────────────────────────────────────── (defun z/blogs-grouped-sitemap (title list) "Sitemap for org-blogs grouped by year and month, newest first." (let ((data (make-hash-table :test 'equal))) ;; Collect into year-key → month-key → list of (link date-str tags-str time) (dolist (entry (cdr list)) (when (consp entry) (let* ((link (car entry)) (ed (z/sitemap--entry-data link (site-path "blogs/"))) (full-path (nth 0 ed)) (date-str (nth 1 ed)) (tags-str (nth 2 ed)) (time (nth 3 ed))) (when time (let* ((year (format-time-string "%Y" time)) (month (format-time-string "%B %Y" time)) (year-table (or (gethash year data) (puthash year (make-hash-table :test 'equal) data)))) (puthash month (cons (list link date-str tags-str time) (gethash month year-table)) year-table)))))) ;; Render (let ((output (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil\n\n" "See the categories: @@html:Categories@@\n\n"))) (dolist (year (sort (hash-table-keys data) #'string>)) (setq output (concat output "* " year "\n")) (let ((year-table (gethash year data))) (dolist (month (sort (hash-table-keys year-table) (lambda (a b) (time-less-p (date-to-time (concat "01 " b)) (date-to-time (concat "01 " a)))))) (setq output (concat output "\n** " month "\n")) (dolist (entry (sort (gethash month year-table) (lambda (a b) (time-less-p (nth 3 b) (nth 3 a))))) (setq output (concat output (format "- %s @@html:%s@@ %s\n" (nth 0 entry) (nth 1 entry) (nth 2 entry)))))))) output))) ;; ───────────────────────────────────────────────────────────────────────────── ;; Year-scoped sitemaps (generic helper) ;; ───────────────────────────────────────────────────────────────────────────── (defun z/year-grouped-sitemap (title list year base-dir categories-rel) "Generic year-scoped, month-grouped sitemap. YEAR is a string like \"2025\". BASE-DIR is the project root. CATEGORIES-REL is the relative href to categories.html." (let ((output (concat "#+TITLE: " title "\n" "#+OPTIONS: toc:nil num:nil\n\n" (format "See the categories: @@html:Categories@@\n\n" categories-rel) "* " year "\n")) (current-month nil)) (dolist (entry (cdr list)) (when (consp entry) (let* ((link (car entry)) (ed (z/sitemap--entry-data link base-dir)) (date-str (nth 1 ed)) (tags-str (nth 2 ed)) (time (nth 3 ed)) (month-str (if time (format-time-string "%B %Y" time) "No date"))) (unless (equal month-str current-month) (setq current-month month-str) (setq output (concat output "\n** " month-str "\n"))) (setq output (concat output (format "- %s @@html:%s@@ %s\n" link date-str tags-str)))))) output)) (defun z/2025-sitemap (title list) "Sitemap for blogs/2025/ grouped by month." (z/year-grouped-sitemap title list "2025" (site-path "blogs/2025/") "../../home/categories.html")) (defun z/2026-sitemap (title list) "Sitemap for blogs/2026/ grouped by month." (z/year-grouped-sitemap title list "2026" (site-path "blogs/2026/") "../../home/categories.html")) ;; ───────────────────────────────────────────────────────────────────────────── ;; Career ;; ───────────────────────────────────────────────────────────────────────────── (defun z/career-sitemap (title list) "Sitemap for posts/career/ grouped by month." (concat (z/year-grouped-sitemap title list "" (site-path "posts/career/") "../../home/categories.html") "\nSee the following page for more details: @@html:Career Intro@@\n")) ;; ───────────────────────────────────────────────────────────────────────────── ;; Categories ;; ───────────────────────────────────────────────────────────────────────────── (defun z/categories-sitemap (title _list) "Generate a categories overview by scanning tags across posts and blogs." (let* ((expanded (org-publish-expand-projects org-publish-project-alist)) (posts (assoc "org-posts" expanded)) (blogs (assoc "org-blogs" expanded)) (files (cl-remove-if (lambda (f) (member (file-name-nondirectory f) '("posts-list.org" "blogs-list.org" "sitemap.org" "categories.org"))) (cl-remove-duplicates (append (and posts (org-publish-get-base-files posts)) (and blogs (org-publish-get-base-files blogs))) :test #'file-equal-p))) (counts (make-hash-table :test 'equal))) (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 (cadr (or (assoc "FILETAGS" kw) (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._"))))) (provide 'sitemaps) ;;; sitemaps.el ends here