Files
org_roam/lisp/build.el
Zaine 963619f883
All checks were successful
Build Roam Site / build (push) Successful in 30s
search key nav fixes x2
2026-05-14 15:09:14 +01:00

595 lines
23 KiB
EmacsLisp
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;; -----------------------------
;; Package bootstrap (BATCH SAFE)
;; -----------------------------
(require 'package)
(setq package-user-dir (expand-file-name "./.packages"))
(setq package-archives
'(("melpa" . "https://melpa.org/packages/")
("gnu" . "https://elpa.gnu.org/packages/")))
(package-initialize)
(defvar site-root
(file-name-directory
(or load-file-name buffer-file-name)))
(defun site-path (path)
(expand-file-name path site-root))
(unless package-archive-contents
(package-refresh-contents))
(unless (package-installed-p 'org-roam)
(package-install 'org-roam))
(require 'ox-publish)
(require 'org)
(require 'ox-html)
(require 'json)
(require 'org-roam)
(require 'ob-latex)
(require 'seq)
(require 'cl-lib)
(use-package htmlize
:ensure t)
;(setq org-export-with-broken-links t)
(setq org-id-track-globally t)
(setq org-id-locations-file
"~/master-folder/org_files/org_roam/.org-id-locations")
(org-id-update-id-locations)
(defun z/org-html-wrap-content-for-stack (orig-fun contents info)
"Wrap Org HTML #content in stack container, leaving postamble outside."
(let* ((html (funcall orig-fun contents info))
(page (file-name-base (plist-get info :output-file)))
(input-file (plist-get info :input-file))
;; Extract planted date from filename (format: YYYYMMDDHHMMSS-title)
(planted-date
(if (string-match "^\\([0-9]\\{14\\}\\)" page)
(let ((date-str (match-string 1 page)))
(condition-case nil
(format-time-string "%Y-%m-%d"
(encode-time
(string-to-number (substring date-str 12 14)) ; second
(string-to-number (substring date-str 10 12)) ; minute
(string-to-number (substring date-str 8 10)) ; hour
(string-to-number (substring date-str 6 8)) ; day
(string-to-number (substring date-str 4 6)) ; month
(string-to-number (substring date-str 0 4)))) ; year
(error "Unknown")))
;; Fallback: try to get creation date from input file
(let ((file-path (if input-file
(expand-file-name input-file (plist-get info :base-directory))
nil)))
(if (and file-path (file-exists-p file-path))
(format-time-string "%Y-%m-%d" (nth 4 (file-attributes file-path)))
"Unknown"))))
;; Get last modified date (last tended to)
(last-tended
(let ((file-path (if input-file
(expand-file-name input-file (plist-get info :base-directory))
nil)))
(if (and file-path (file-exists-p file-path))
(format-time-string "%Y-%m-%d" (nth 5 (file-attributes file-path)))
"Unknown")))
(stack-close
"</div></article></div></div>"))
(setq html
(replace-regexp-in-string
"<div id=\"content\" class=\"content\">"
(format
"<div id=\"stack-root\">
<div class=\"stack-track\">
<article class=\"stack-pane pane-root\" data-url=\"/%s.html\">
<div id=\"content\" class=\"content\">"
page)
html
nil t))
;; Wrap title with metadata section — edit button removed
(setq html
(replace-regexp-in-string
"<h1 class=\"title\">\\(.*\\)</h1>"
(lambda (match)
(let ((title-text (match-string 1 match)))
(format
"<div class=\"title-section\">
<div class=\"title-controls\">
<button class=\"pane-fullscreen\" aria-label=\"Fullscreen\">⤢</button>
<button class=\"pane-close\" aria-label=\"Close pane\">×</button>
</div>
<h1 class=\"title\">%s</h1>
<div class=\"title-metadata\">
<span class=\"metadata-item\">
<span class=\"metadata-label\">planted</span>
<span class=\"metadata-value\">%s</span>
</span>
<span class=\"metadata-item\">
<span class=\"metadata-label\">tended</span>
<span class=\"metadata-value\">%s</span>
</span>
</div>
</div>"
title-text planted-date last-tended)))
html
nil t))
(setq html
(replace-regexp-in-string
"<div id=\"postamble\" class=\"status\">"
(concat stack-close "\n<div id=\"postamble\" class=\"status\">")
html
nil t))
html))
(advice-add 'org-html-template :around #'z/org-html-wrap-content-for-stack)
(add-to-list 'load-path default-directory)
(defvar z-shared-head
"
<link rel=\"icon\" type=\"image/png\" sizes=\"48x48\" href=\"/assets/icons/icons8-z-key-64.png\" />
<link rel=\"stylesheet\" href=\"/assets/styles/style.css?v=20260514-search-modal\" />
<link rel=\"stylesheet\" href=\"/assets/styles/bigger-picture.min.css\" />
<link rel=\"stylesheet\" href=\"/assets/styles/media.css?v=20260514-search-modal\" />
<link rel=\"stylesheet\" href=\"/assets/styles/cookbook.css\" />
<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />
<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />
<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+Arabic:wght@400;700&display=swap\" rel=\"stylesheet\" />
<script src=\"/assets/scripts/script.js?v=20260514-search-modal\" defer></script>
<script src=\"/assets/scripts/search.js?v=20260514-search-modal\" defer></script>
<script src=\"/assets/scripts/bigger-picture.min.js\" defer></script>
<script src=\"/assets/scripts/svg-pan-zoom.min.js\" defer></script>
<script src=\"/assets/scripts/gallery-init.js\" defer></script>
<script src=\"/assets/scripts/cookbook.js\" defer></script>
<script src=\"/assets/scripts/mermaid.min.js\"></script>
<script>
MathJax = {
tex: {
inlineMath: [['\\\\(', '\\\\)']],
displayMath: [['\\\\[', '\\\\]']],
processEscapes: true
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']
}
};
</script>
<script src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js\" async></script>
<style>
#mv-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9999;align-items:center;justify-content:center;}
#mv-overlay.open{display:flex;}
#mv-modal{background:#fff;border:1px solid #ddd;border-radius:8px;width:90vw;max-width:1100px;height:85vh;display:flex;flex-direction:column;overflow:hidden;}
#mv-toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid #eee;flex-shrink:0;}
#mv-toolbar button{border:1px solid #ccc;border-radius:5px;padding:4px 12px;font-size:13px;cursor:pointer;background:none;}
#mv-toolbar button:hover{background:#f5f5f5;}
#mv-canvas{flex:1;overflow:hidden;position:relative;cursor:grab;user-select:none;}
#mv-canvas.dragging{cursor:grabbing;}
#mv-inner{position:absolute;top:0;left:0;transform-origin:0 0;padding:20px;}
.mermaid-view-btn{display:inline-block;margin-top:6px;padding:3px 10px;font-size:12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;background:none;color:inherit;}
</style>
<div id=\"mv-overlay\">
<div id=\"mv-modal\">
<div id=\"mv-toolbar\">
<button onclick=\"mvZoom(-0.2)\"></button>
<span id=\"mv-lbl\" style=\"min-width:42px;text-align:center;font-size:13px;\">100%</span>
<button onclick=\"mvZoom(0.2)\">+</button>
<button onclick=\"mvReset()\">Reset</button>
<button onclick=\"mvFit()\">Fit</button>
<button onclick=\"mvClose()\" style=\"margin-left:auto;\">✕ Close</button>
</div>
<div id=\"mv-canvas\"><div id=\"mv-inner\"></div></div>
<div style=\"font-size:12px;color:#888;padding:6px 14px;border-top:1px solid #eee;\">Scroll to zoom · Drag to pan · Esc to close</div>
</div>
</div>
<script>
(function(){
var sc=1,tx=0,ty=0,drag=false,lx=0,ly=0;
function C(){return document.getElementById('mv-canvas');}
function N(){return document.getElementById('mv-inner');}
function L(){return document.getElementById('mv-lbl');}
function apply(){N().style.transform='translate('+tx+'px,'+ty+'px) scale('+sc+')';L().textContent=Math.round(sc*100)+'%';}
window.mvZoom=function(d,cx,cy){var r=C().getBoundingClientRect(),p=sc;cx=cx!=null?cx:r.width/2;cy=cy!=null?cy:r.height/2;sc=Math.max(0.1,Math.min(10,sc+d));tx+=(cx-tx)*(1-sc/p);ty+=(cy-ty)*(1-sc/p);apply();};
window.mvReset=function(){sc=1;tx=0;ty=0;apply();};
window.mvFit=function(){var cr=C().getBoundingClientRect(),ir=N().getBoundingClientRect();var cw=ir.width/sc,ch=ir.height/sc;sc=Math.min(cr.width/cw,cr.height/ch)*0.9;tx=(cr.width-cw*sc)/2;ty=(cr.height-ch*sc)/2;apply();};
window.mvOpen=function(el){
var svg = el.querySelector('svg');
if (!svg) return;
var clone = svg.cloneNode(true);
var vb = clone.getAttribute('viewBox');
if (vb) {
var parts = vb.split(' ');
clone.setAttribute('width', parts[2]);
clone.setAttribute('height', parts[3]);
}
clone.style.maxWidth = 'none';
clone.style.display = 'block';
N().innerHTML = '';
N().appendChild(clone);
sc=1;tx=0;ty=0;apply();
document.getElementById('mv-overlay').classList.add('open');
setTimeout(mvFit, 100);
};
window.mvClose=function(){document.getElementById('mv-overlay').classList.remove('open');};
window.addEventListener('load',function(){
C().addEventListener('wheel',function(e){e.preventDefault();var r=C().getBoundingClientRect();mvZoom(e.deltaY<0?.15:-.15,e.clientX-r.left,e.clientY-r.top);},{passive:false});
C().addEventListener('mousedown',function(e){drag=true;lx=e.clientX;ly=e.clientY;C().classList.add('dragging');});
window.addEventListener('mousemove',function(e){if(!drag)return;tx+=e.clientX-lx;ty+=e.clientY-ly;lx=e.clientX;ly=e.clientY;apply();});
window.addEventListener('mouseup',function(){drag=false;C().classList.remove('dragging');});
document.addEventListener('keydown',function(e){if(e.key==='Escape')mvClose();});
document.getElementById('mv-overlay').addEventListener('click',function(e){if(e.target.id==='mv-overlay')mvClose();});
});
})();
</script>
<script>
window.addEventListener('load', function() {
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
document.querySelectorAll('.mermaid').forEach(el => {
el.innerHTML = el.innerHTML
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
});
mermaid.run({ querySelector: '.mermaid' }).then(function() {
document.querySelectorAll('.mermaid').forEach(function(el) {
if (el.querySelector('svg')) {
var btn = document.createElement('button');
btn.textContent = 'View diagram ↗';
btn.className = 'mermaid-view-btn';
btn.onclick = function(){ mvOpen(el); };
el.parentNode.insertBefore(btn, el.nextSibling);
}
});
});
});
</script>
"
)
(defvar z-preamble
"
<div class=\"banner-header\">
<div class=\"banner-left\">
</div>
<div class=\"banner-search\">
<button id=\"search-open\"
class=\"search-trigger\"
type=\"button\"
aria-haspopup=\"dialog\"
aria-controls=\"search-modal\">
<span>Search notes</span>
<kbd>/</kbd>
</button>
<div id=\"search-modal\"
class=\"search-modal\"
role=\"dialog\"
aria-modal=\"true\"
aria-labelledby=\"search-modal-title\"
hidden>
<div class=\"search-backdrop\" data-search-close></div>
<div class=\"search-dialog\">
<div class=\"search-panel\">
<div class=\"search-input-wrap\">
<span class=\"search-icon\" aria-hidden=\"true\">⌕</span>
<input id=\"search-box\"
type=\"search\"
placeholder=\"Search notes...\"
aria-label=\"Search notes\"
autocomplete=\"off\" />
<button class=\"search-close\"
type=\"button\"
aria-label=\"Close search\"
data-search-close>&times;</button>
</div>
<div id=\"search-modal-title\" class=\"search-title\">Search notes</div>
<div id=\"search-results\" role=\"listbox\" aria-label=\"Search results\"></div>
<div class=\"search-empty\" id=\"search-empty\">Type at least 2 characters to search.</div>
</div>
</div>
</div>
</div>
<div class=\"banner-actions\">
<button id=\"theme-toggle\" aria-label=\"Toggle theme\" title=\"Toggle light/dark\"></button>
<button id=\"close-all\">close all</button>
</div>
</div>
"
)
(defvar z-postamble
"<footer>
<div class=\"copyright-container\">
<div class=\"copyright\">
Copyright &copy; 2022-2026 Zaine Qayyum. All rights reserved unless otherwise noted.</div></div>
<div class=\"generated\">
Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> <a href=\"https://www.gnu.org\">GNU</a>/<a href=\"https://www.kernel.org/\">Linux</a>
</div>
</footer>")
(defvar z/build-full-rebuild t
"If non-nil, generate expensive artifacts like search-index.json.")
;; Babel language support
(org-babel-do-load-languages
'org-babel-load-languages
'((latex . t)
(org . t)))
(setq org-confirm-babel-evaluate nil ;; Disable confirmation for code block evaluation
org-export-with-smart-quotes t ;; turns " into curly quotes
;;org-html-self-link-headlines t ;; headlines link to self
org-html-validation-link nil ;; dont validate html (in order to check it is up to web standards)
org-html-inline-images t ;; self-expl
org-export-with-sub-superscripts t ;; render h_x as large h with subscript x
)
(load-file (expand-file-name "lisp/macros.el" default-directory))
;; Configure LaTeX image generation
(setq org-latex-create-formula-image-program 'imagemagick)
(defun z/org-html-mermaid-block (code _lang)
"Export a mermaid src block as a mermaid div."
(format "<div class=\"mermaid\">\n%s\n</div>" 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 "<div class=\"mermaid\">\n%s\n</div>" code))
(funcall orig-fun src-block contents info))))
(advice-add 'org-html-src-block :around #'z/org-html-src-block-mermaid)
(defun my/get-org-roam-backlinks (filename)
"Return backlinks for the given org-roam file. FILENAME should be the full path to the org-roam file."
(save-excursion
(with-current-buffer (find-file-noselect filename)
(goto-char (point-min))
(if (and (featurep 'org-roam) (org-roam-node-at-point))
(let* ((node (org-roam-node-at-point))
(backlinks (org-roam-backlinks-get node)))
(mapcar (lambda (backlink)
(let ((source-node (org-roam-backlink-source-node backlink)))
(list
:file (org-roam-node-file source-node)
:title (org-roam-node-title source-node)
:content (with-current-buffer
(find-file-noselect (org-roam-node-file source-node))
(goto-char (org-roam-backlink-point backlink))
(thing-at-point 'sentence t))
:link (format "[[id:%s][%s]]"
(org-roam-node-id source-node)
(org-roam-node-title source-node)))))
backlinks)
)
nil))))
(defun my/org-export-insert-backlinks (_backend)
"Insert Org-roam backlinks before export."
(when (and (buffer-file-name)
(not (string-match-p "all-files\\.org$" (buffer-file-name)))
(featurep 'org-roam)
(org-roam-node-at-point))
(let ((backlinks (my/get-org-roam-backlinks (buffer-file-name))))
(when backlinks
(goto-char (point-max))
(insert "\n#+ATTR_HTML: :class backlinks-section :id backlinks\n")
(insert "* Backlinks\n")
(insert "#+ATTR_HTML: :class backlinks-list\n")
(dolist (bl backlinks)
(insert (format "- %s\n" (plist-get bl :link))))))))
(setq org-id-locations-file
(expand-file-name ".org-id-locations"
(getenv "HOME")))
(setq org-roam-directory site-root)
(org-id-update-id-locations)
(setq org-html-link-home "")
(setq org-html-link-up "")
(defun z/generate-all-files-org ()
"Generate all-files.org from all-files.json."
(let* ((base-dir site-root)
(json-file (expand-file-name "all-files.json" base-dir))
(org-file (expand-file-name "all-files.org" base-dir)))
(when (file-exists-p json-file)
(message "🧩 Generating all-files.org from JSON…")
(let* ((json-object-type 'alist)
(json-array-type 'list)
(json-key-type 'symbol)
(data (json-read-file json-file))
(groups (alist-get 'groups data)))
(with-temp-file org-file
(insert "#+title: All Files\n")
(insert "#+options: toc:nil num:nil\n\n")
(insert "#+begin_comment\n")
(insert "This file is auto-generated. Do not edit manually.\n")
(insert "#+end_comment\n\n")
(dolist (group groups)
(let ((letter (symbol-name (car group)))
(entries (cdr group)))
(insert (format "* %s\n" letter))
(dolist (entry entries)
(let ((id (alist-get 'id entry))
(title (alist-get 'title entry)))
(insert (format "- [[id:%s][%s]]\n" id title))))
(insert "\n")))))
(message "✅ all-files.org generated"))))
(setq org-html-mathjax-options
'((path "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js")
(scale 1.0)
(align "center")
(font "mathjax-modern")
(overflow "overflow")
(autonumber "AMS")
(multlinewidth "85%")
(tagside "right")
(tagindent ".8em")))
(setq org-html-mathjax-template
"<script>
MathJax = {
tex: {
inlineMath: [['\\\\(', '\\\\)']],
displayMath: [['\\\\[', '\\\\]']]
}
};
</script>
<script src=\"%PATH\" async></script>")
;; -------------------------------------------------------
;; MOV → MP4 conversion
;; Converts all .mov files found anywhere under assets/ to
;; .mp4 siblings IN THE SOURCE directory before publishing.
;; org-publish then copies both the .mov and the .mp4.
;; Skips conversion if an up-to-date .mp4 already exists.
;; -------------------------------------------------------
(defun z/convert-mov-to-mp4 (assets-dir)
"Recursively convert every .mov under ASSETS-DIR to an .mp4 sibling.
Skips files where the .mp4 already exists and is newer than the .mov."
(message "🎬 Scanning %s for .mov files…" assets-dir)
(let ((mov-files
(directory-files-recursively assets-dir "\\.mov$" nil)))
(if (null mov-files)
(message " (no .mov files found)")
(dolist (mov mov-files)
(let* ((mp4 (concat (file-name-sans-extension mov) ".mp4"))
(mov-mtime (float-time (nth 5 (file-attributes mov))))
(mp4-mtime (if (file-exists-p mp4)
(float-time (nth 5 (file-attributes mp4)))
-1)))
(if (> mp4-mtime mov-mtime)
(message " [skip] %s (mp4 is up to date)" (file-name-nondirectory mov))
(message " [convert] %s → %s"
(file-relative-name mov assets-dir)
(file-name-nondirectory mp4))
(let ((exit-code
(call-process "ffmpeg" nil nil nil
"-i" mov
"-c:v" "libx264"
"-preset" "fast"
"-crf" "23"
"-c:a" "aac"
"-b:a" "128k"
"-movflags" "+faststart"
"-y" mp4)))
(if (= exit-code 0)
(message " [done] %s" (file-name-nondirectory mp4))
(message " [ERROR] ffmpeg exited %d for %s"
exit-code (file-name-nondirectory mov))))))))))
(setq org-publish-project-alist
`(
("org-roam"
:base-directory "/home/zaine/master-folder/org_files/org_roam/"
:base-extension "org"
:publishing-directory "/home/zaine/master-folder/org_files/org_roam/output/"
:recursive t
:publishing-function org-html-publish-to-html
:with-author nil
:with-creator nil
:with-date t
:with-toc nil
:section-numbers nil
:html-preamble ,z-preamble
:html-postamble ,z-postamble
:html-head ,z-shared-head)
("org-static"
:base-directory "/home/zaine/master-folder/org_files/org_roam/assets/"
:base-extension "css\\|js\\|png\\|jpg\\|gif\\|svg\\|mp4\\|mp3\\|mov"
:publishing-directory "/home/zaine/master-folder/org_files/org_roam/output/assets/"
:recursive t
:publishing-function org-publish-attachment)))
(defun z/org-publish-quiet (project force)
"Publish PROJECT quietly. If FORCE is non-nil, force publishing."
(let ((inhibit-message t)
(message-log-max nil))
(org-publish project force)))
(defun z/refresh-org-roam ()
"Force Org-roam and Org ID caches to refresh (needed for synced notes)."
(message "starting to refresh...")
;; Update ID locations
(org-id-update-id-locations)
;; Rebuild roam database
(when (featurep 'org-roam)
(org-roam-db-clear-all)
(org-roam-db-sync))
(message "✅ Org-roam DB refreshed"))
(defun z/build-full ()
"Full rebuild: Org, assets, and search index."
(interactive)
(let* ((start-time (current-time)))
(message "Starting full rebuild at %s"
(format-time-string "%Y-%m-%d %H:%M:%S" start-time))
(org-id-update-id-locations)
;; Convert any .mov files before publishing so org-publish
;; picks up the .mp4 siblings automatically.
(z/convert-mov-to-mp4
"/home/zaine/master-folder/org_files/org_roam/assets/")
(setq z/build-full-rebuild t)
(z/generate-all-files-org)
(z/org-publish-quiet "org-roam" t)
(z/org-publish-quiet "org-static" t)
(let ((elapsed (float-time (time-subtract (current-time) start-time))))
(message "✅ Full rebuild complete in %.2f seconds" elapsed))))
(defun z/build-fast ()
"Fast rebuild: Org + assets only (no JSON)."
(interactive)
;; Convert MOVs in fast builds too — cheap (skips up-to-date files).
(z/convert-mov-to-mp4
"/home/zaine/master-folder/org_files/org_roam/assets/")
(setq z/build-full-rebuild nil)
(org-publish "org-roam" t)
(org-publish "org-static" t)
(message "⚡ Fast rebuild complete (no search index)"))
(cond
((member "--fast" command-line-args-left)
(setq command-line-args-left (remove "--fast" command-line-args-left))
(z/build-fast))
(t
(z/build-full)))