Publishing with org-mode

Intended for use with: Emacs configuration for writing. org-mode works perfectly as a minimal static site generator.

(setq package-user-dir (expand-file-name "~/.config/emacs-writing/elpa"))
(package-initialize)
(require 's)
(require 'org)
(setq org-link-file-path-type 'relative)
(require 'ox)
(setq org-export-headline-levels 10)
(setq org-export-with-toc t)
(require 'org-contrib)
(require 'ox-extra)
(ox-extras-activate '(ignore-headlines))
(require 'ox-html)
(setq org-html-with-latex t)
;; (setq org-html-with-latex 'dvisvgm)
(setq org-html-doctype "html5"
      org-html-html5-fancy t
      org-html-prefer-user-labels t)
(require 'ox-publish)
(require 'oc)
(setq org-cite-global-bibliography
      (mapcar 'file-truename
              (directory-files-recursively "~/documents/bibliographies/" "\\.bib$")))
(require 'oc-csl)
(setq org-cite-export-processors
      '((t . (csl "~/downloads/ieee.csl"))))
(require 'org-ref)
(require 'htmlize)
(setq org-html-htmlize-output-type 'css
      org-html-head-include-default-style nil)
(defun my/org-html-eqref-export (path desc format info)
  "Export `eqref:path` links to HTML, replacing text with the mapped equation number."
  (cond
   ((eq format 'html)
    (let* (;; 1. Look up the label (path) in our preprocessed hash table
           (eqnum (when (boundp 'my/org-latex-eqnum-hash)
                    (gethash path my/org-latex-eqnum-hash)))

           ;; 2. Set anchor text: (eqnum) if found, otherwise fallback to (??)
           (anchor-text (if eqnum
                            (format "(%s)" eqnum)
                          "(???)")))

      ;; 3. Return the formatted HTML link
      (format "<a href=\"#eqnum-%s\" class=\"eqref\">%s</a>" path anchor-text)))

   ;; For LaTeX export, we just let the native \eqref macro handle it
   ((eq format 'latex)
    (format "\\eqref{%s}" path))

   ;; Fallback for other export backends (like plain text)
   (t (or desc (format "(%s)" path)))))

;; Register the link parameter as before
(org-link-set-parameters "eqref"
                         :follow (lambda (path _) (org-link-search path))
                         :export #'my/org-html-eqref-export)

;; (defun my/org-html-eqref-export (path desc format info)
;;   "Export `eqref:path` links to HTML as <a href=\"#path\">↑</a> or custom description."
;;   (cond
;;    ((eq format 'html)
;;     (let ((anchor-text (or desc "(↑)")))
;;       (format "<a href=\"#%s\" class=\"eqref\">%s</a>" path anchor-text)))
;;    ((eq format 'latex)
;;     (format "\\eqref{%s}" path))
;;    (t (or desc path))))

;; (org-link-set-parameters "eqref"
;;                          :follow (lambda (path _) (org-link-search path))
;;                          :export #'my/org-html-eqref-export)
(require 'org-mem)
(setq org-mem-watch-dirs '("~/giabao.xyz/org/")
      org-mem-do-look-everywhere nil
      org-mem-do-sync-with-org-id t)
(org-mem-updater-mode)
(require 'org-mem-roamy)
(org-mem-roamy-db-mode)
(require 'org-node)
(org-node-cache-mode)
(require 'org-node-backlink)
(setq org-node-backlink-drawer-formatter
      #'org-node-backlink-format-as-bullet-with-time)
(defun org-node-backlink--fix-nearby-drawer (&optional remove)
  "Update nearby backlinks drawer so it reflects current reality.
If REMOVE non-nil, remove it instead."
  (if remove
      (org-node--delete-drawer "BACKLINKS")
    (let* ((id (org-entry-get nil "ID"))
           (entry (org-mem-entry-by-id id))
           (portal-ids (mapcar #'car
                               (emacsql (org-mem-roamy-db)
                                        [:select node_id
                                                 :from tags
                                                 :where (in tag $v1)]
                                        (vconcat org-excluded-tags))))
           (all-origin-ids (when entry
                         (thread-last
                           (append (org-mem-id-links-to-entry entry)
                                   (org-mem-roam-reflinks-to-entry entry))
                           (seq-keep #'org-mem-link-nearby-id)
                           (delete id)
                           (delete-dups)
                           ;; Shouldn't be necessary if tables are correct, but
                           ;; don't assume `org-mem-updater-mode' is flawless
                           (seq-filter #'org-mem-entry-by-id))))
           (origin-ids (seq-difference all-origin-ids portal-ids))
           (org-node-backlink--inhibit-flagging t))
      (if (null origin-ids)
          (org-node--delete-drawer "BACKLINKS")
        (save-excursion
          (save-restriction
            (org-node-narrow-to-drawer-create
             "BACKLINKS" org-node-backlink-drawer-positioner)
            (let* ((col (current-indentation))
                   (lines (split-string (buffer-string) "\n" t))
                   (already-present-ids
                    (seq-keep #'org-node-backlink--extract-id lines))
                   (to-add      (seq-difference   origin-ids already-present-ids))
                   (to-remove   (seq-difference   already-present-ids origin-ids))
                   (to-reformat (seq-intersection already-present-ids origin-ids)))
              ;; Add new, remove stale, reformat the rest
              (dolist (id to-remove)
                (save-excursion
                  (search-forward id)
                  (delete-line)))
              (dolist (id to-reformat)
                (save-excursion
                  (search-forward id)
                  (back-to-indentation)
                  (let* ((line (buffer-substring-no-properties (point) (pos-eol)))
                         (reformatted (org-node-backlink--reformat-line line)))
                    (unless (equal line reformatted)
                      (atomic-change-group
                        (delete-region (point) (pos-eol))
                        (insert reformatted))))))
              (dolist (id to-add)
                (let ((title (org-mem-title-maybe (org-mem-entry-by-id id))))
                  (insert (funcall org-node-backlink-drawer-formatter id title))
                  (newline)
                  (indent-to col)))
              ;; Membership is correct, now re-sort
              (let ((sorted-lines (sort (split-string (buffer-string) "\n" t)
                                        org-node-backlink-drawer-sorter)))
                (when org-node-backlink-drawer-sort-in-reverse
                  (setq sorted-lines (nreverse sorted-lines)))
                (unless (equal lines sorted-lines)
                  (atomic-change-group
                    (delete-region (point-min) (point-max))
                    (insert (string-join sorted-lines "\n"))))))))))))

1. org-mode

(defvar org-excluded-tags)
(setq org-excluded-tags '("portal"))

2. KaTeX

Disable MathJax! Use KaTex sparingly!

(setq org-html-mathjax-template "")

Re-define some functions for more expressive sectioning of the page.

(defun org-html-latex-environment (latex-environment _contents info)
  "Transcode a LATEX-ENVIRONMENT element from Org to HTML.
CONTENTS is nil. INFO is a plist holding contextual information."
  (let ((processing-type (plist-get info :with-latex))
        (latex-frag (org-remove-indentation
                     (org-element-property :value latex-environment)))
        (label (org-html--reference latex-environment info t)))
    (if (memq processing-type '(t mathjax))
        (let ((rendered (org-html--as-latex latex-environment info latex-frag)))
          (if (and (org-string-nw-p label) label)
              ;; (format "<div data-eqnum=\"%d\" id=\"eqnum-%s\" class=\"latex-environment\">\n%s\n</div>"
              (format "<div style=\"--eqnum: '%d';\" id=\"eqnum-%s\" class=\"latex-environment\">\n%s\n</div>"
                      (gethash label my/org-latex-eqnum-hash) label rendered)
            rendered))
      (org-html--wrap-latex-environment
       (org-html--as-latex latex-environment info latex-frag)
       label))))

2.1. Labelling and referecing KaTeX equations

3. siunitx replacement

(defun my/siunitx-format-number (num-str)
  "Convert numbers and scientific notation like '-3.14e6' to LaTeX math notation.
Handles negative numbers, decimals, and plain numbers without exponents."
  (save-match-data
    ;; Regex breakdown:
    ;; ^\\([-+]?[0-9.]+\\)       -> Group 1: The base number (optional sign, digits/decimals)
    ;; \\(?:[eE]\\([-+]?[0-9]+\\)\\)?$ -> Group 2: The exponent (optional sign, digits). 
    ;;                                    The \\(?:...\\)? makes the whole 'e' block optional.
    (if (string-match "^\\([-+]?[0-9.]+\\)\\(?:[eE]\\([-+]?[0-9]+\\)\\)?$" num-str)
        (let ((base (match-string 1 num-str))
              (exp  (match-string 2 num-str)))
          (if exp
              ;; If there is an 'e' part, format as scientific notation
              (format "%s \\times 10^{%s}" base exp)
            ;; If there is no 'e' part, just return the base number
            base))
      ;; Fallback: If it's not a recognized number at all, return it unchanged
      num-str)))

(defvar my/siunitx-unit-mapping
  '(;; Prefixes
    ("femto"  . "f")
    ("pico"   . "p")
    ("nano"   . "n")
    ("micro"  . "\\mu ")
    ("milli"  . "m")
    ("centi"  . "c")
    ("deci"   . "d")
    ("kilo"   . "k")
    ("mega"   . "M")
    ("giga"   . "G")
    ("tera"   . "T")

    ;; Base Units
    ("second" . "s")
    ("gram"   . "g")
    ("meter"  . "m")
    ("hertz"  . "Hz")
    ("joule"  . "J")
    ("newton" . "N")
    ("watt"   . "W")
    ("volt"   . "V")
    ("ampere" . "A")
    ("celsius". "^{\\circ}C")

    ;; Modifiers (Very handy!)
    ("per"    . "/")
    ("squared". "^2")
    ("cubic"  . "^3")

    ;; Unit abbreviations
    ("MeV"    . "MeV")
    )
  "Dictionary mapping siunitx macros (without backslash) to their replacements.")

(defun my/siunitx-format-unit (unit-str)
  "Convert siunitx macros to mathrm strings in a single pass using a dictionary."
  (replace-regexp-in-string
   "\\\\\\([A-Za-z]+\\)"
   (lambda (match)
     ;; `match` is the matched string (e.g., "\femto").
     ;; `(substring match 1)` strips the leading backslash, giving "femto".
     (let* ((key (substring match 1)) 
            (val (cdr (assoc key my/siunitx-unit-mapping))))
       ;; If we found a replacement in the table, return it.
       ;; If not, fallback to returning the key (stripping the backslash anyway).
       (if val val key)))
   unit-str
   t t)) ;; 't t' ensures the replacement is treated as a literal string

(defun my/siunitx-html-filter (text backend info)
  "Filter to replace siunitx commands in LaTeX fragments during HTML export."
  (let ((modified nil)
        (orig-text text))

    ;; 1. Process \qty{num}{unit}
    ;; FIX: \\qty in a string evaluates to \qty. We need \\\\qty to search for literal \qty.
    (when (string-match "\\\\qty{" text)
      (setq text (replace-regexp-in-string
                  "\\\\qty{\\([^}]+\\)}{\\([^}]+\\)}"
                  (lambda (m)
                    (format "%s \\, \\mathrm{%s}"
                            (my/siunitx-format-number (match-string 1 m))
                            (my/siunitx-format-unit (match-string 2 m))))
                  text nil t))
      (setq modified t))

    ;; 2. Process \num{num}
    (when (string-match "\\\\num{" text)
      (setq text (replace-regexp-in-string
                  "\\\\num{\\([^}]+\\)}"
                  (lambda (m)
                    (my/siunitx-format-number (match-string 1 m)))
                  text nil t))
      (setq modified t))

    ;; 3. Process \unit{unit}
    (when (string-match "\\\\unit{" text)
      (setq text (replace-regexp-in-string
                  "\\\\unit{\\([^}]+\\)}"
                  (lambda (m)
                    (format "\\mathrm{%s}"
                            (my/siunitx-format-unit (match-string 1 m))))
                  text nil t))
      (setq modified t))

    ;; Smart Math Wrapper:
    (if (and modified 
             (not (string-match-p "^\\s-*\\(\\\\[([]\\|\\$\\|\\\\begin{\\)" orig-text)))
        (format "\\( %s \\)" text)
      text)))

;; Add our filter function to the LaTeX fragment filters
;; (add-to-list 'org-export-filter-plain-text-functions 'my/siunitx-html-filter)
(add-to-list 'org-export-filter-latex-fragment-functions 'my/siunitx-html-filter)
(add-to-list 'org-export-filter-latex-environment-functions 'my/siunitx-html-filter)

4. org-publish

(defvar org-publish-localhost)
(defvar org-publish-dir)
(defvar org-publish-url)

(setq org-publish-localhost nil)
(setq org-publish-dir "/home/bao/giabao.xyz/")

(if (equal org-publish-localhost t)
    (setq org-publish-url "http://localhost:8080")
  (setq org-publish-url "https://giabao.xyz"))
(if (equal org-publish-localhost t)
    (setq org-link-abbrev-alist
          '(("img" . "http://localhost:8080/img/%s")
            ("vid" . "http://localhost:8080/vid/%s")))
  (setq org-link-abbrev-alist
        '(("img" . "https://giabao.xyz/img/%s")
          ("vid" . "https://giabao.xyz/vid/%s"))))

4.1. Preprocessing

(defvar my/org-latex-eqnum-hash nil
  "Hash table mapping Org LaTeX environment names to equation numbers.")

(defun my/org-preprocess-latex-equations (&optional backend)
  "Scan the current Org buffer for labeled latex-environments and assign equation numbers.
Only equations with a `#+name:` label are counted and stored in the hash table.
BACKEND is accepted so this can be attached to `org-export-before-parsing-hook'."
  ;; Initialize/clear the hash table
  (setq my/org-latex-eqnum-hash (make-hash-table :test 'equal))

  (let ((counter 1)
        ;; Parse the current buffer into an abstract syntax tree (AST)
        (tree (org-element-parse-buffer)))

    ;; Map over all 'latex-environment elements in the AST
    (org-element-map tree 'latex-environment
      (lambda (env)
        (let ((label (org-element-property :name env)))

          ;; ONLY proceed if the equation actually has a #+name: label
          (when label
            ;; Push the pair (label, counter) to the hash table
            (puthash label counter my/org-latex-eqnum-hash)

            ;; Increment the counter for the next *labeled* environment
            (setq counter (1+ counter)))))))

  ;; Return the hash table purely for convenience/debugging
  my/org-latex-eqnum-hash)

(add-hook 'org-export-before-parsing-functions #'my/org-preprocess-latex-equations)
(defun my/org-html-proof-block-filter (text backend info)
  "Convert #+begin_proof HTML blocks to <details class=\"proof\">."
  (when (org-export-derived-backend-p backend 'html)
    (replace-regexp-in-string
     ;; Match opening <div ... class="proof" ...> with optional whitespace,
     ;; OR closing </div> with optional whitespace before end-of-string.
     "\\`<div[^>]*class=\"proof\"[^>]*>[ \t\n]*\\|</div>[ \t\n]*\\'"
     (lambda (match)
       (if (string-prefix-p "<div" match)
           "<details class=\"proof\">\n<summary class=\"proof-text\">Proof</summary>\n<div class=\"proof-content\">\n"
         "</div>\n</details>\n"))
     text)))

(add-hook 'org-export-filter-special-block-functions
          #'my/org-html-proof-block-filter)
(defun my/org-html-algorithm-block-filter (text backend info)
  "Convert HTML <div class=\"algorithm\"> blocks to <pre class=\"algorithm\"> with \\begin{algorithm}."
  (when (org-export-derived-backend-p backend 'html)
    (replace-regexp-in-string
     "<div[^>]*class=\"algorithm\"[^>]*>\\(\\(?:.\\|\n\\)*?\\)\n</div>"
     (lambda (match)
       (format "<pre class=\"algorithm\">\n\\begin{algorithm}%s\\end{algorithm}</pre>"
               (match-string 1 match)))
     text nil 'literal)))

(add-hook 'org-export-filter-special-block-functions
          #'my/org-html-algorithm-block-filter)
(defun my/org-preprocess-block-title (backend)
  "Preprocess custom block's title."
  (when (org-export-derived-backend-p backend 'html)
    (goto-char (point-min))
    (while (re-search-forward "^#\\+begin_\\([a-z]+\\) \\[\\([a-zA-Z0-9,='(/)\{\}\\ -]+\\)\\]" nil t)
      (let ((env  (match-string 1))
            (name (match-string 2)))
        (replace-match "")
        (insert (format "#+attr_html: :title %s\n#+begin_%s" name env))))))

(add-hook 'org-export-before-processing-functions
          #'my/org-preprocess-block-title)
(defun my/org-export-format-backlinks-drawer (backend)
  "Preprocess :BACKLINKS: drawer."
  (when (org-export-derived-backend-p backend 'html)
    (save-excursion
      (goto-char (point-min))
      ;; Search for :BACKLINKS: drawer syntax
      (while (re-search-forward "^[ \t]*:BACKLINKS:[ \t]*$" nil t)
        (let ((drawer-start (match-beginning 0))
              (contents-start (line-beginning-position 2))
              (drawer-end (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)))
          (when drawer-end
            (let ((contents (buffer-substring-no-properties contents-start (match-beginning 0))))
              ;; Replace drawer with a custom block
              (delete-region drawer-start (point))
              (goto-char (point-max))
              (insert (format "* Backlinks
:PROPERTIES:
:UNNUMBERED: notoc
:END:
%s" contents)))))))))

(add-hook 'org-export-before-processing-functions
          #'my/org-export-format-backlinks-drawer)
(defun my/org-html-katex-options (backend)
  "#+katex_options: pseudocode:t"
  (when (org-export-derived-backend-p backend 'html)
    (save-excursion
      (goto-char (point-min))
      (when (re-search-forward
             (rx line-start "#+katex_options:" (0+ space) "pseudocode:t")
             nil t)

        (beginning-of-line 2)
        (insert "#+html_head_extra: <link rel=\"stylesheet\" href=\"../css/pseudocode.css\">\n")
        (insert "#+html_head_extra: <script src=\"../js/pseudocode.js\"></script>\n\n")

        (goto-char (point-max))
        (insert "\n@@html:<script>pseudocode.renderElement(document.getElementsByClassName(\"algorithm\")[0],
{ lineNumber: true });</script>@@\n")))))

(add-hook 'org-export-before-processing-functions
          #'my/org-html-katex-options)

4.2. Content processing

(defun my/org-html--build-head (plist)
  (let* ((dir (expand-file-name (plist-get plist :publishing-directory)))
         (org-css (file-relative-name "html/css/org.css" dir))
         (math-css (file-relative-name "html/css/math.css" dir))
         (folding-js (file-relative-name "html/js/folding.js" dir)))
    (concat
     (format "<link rel=\"stylesheet\" href=\"%s\" type=\"text/css\" />
<link rel=\"stylesheet\" href=\"%s\" type=\"text/css\" />
<script src=\"%s\" defer></script>\n" org-css math-css folding-js)
;;      "<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=League+Spartan:wght@100..900&display=swap\" rel=\"stylesheet\">"
     "<style>"
     "/* vietnamese */
@font-face {
  font-family: 'League Spartan';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwvZ_LZmFDA.woff2) format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
  font-family: 'League Spartan';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwvZ-LZmFDA.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'League Spartan';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwvZwLZk.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
"
     ;;      "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
     ;; <link rel=\"preconnepct\" href=\"https://fonts.gstatic.com\" crossorigin>
     ;; <link href=\"https://fonts.googleapis.com/css2?family=League+Spartan:wght@100..900&family=Libre+Baskerville:ital,wght@0,400..700;1,400..700&display=swap\" rel=\"stylesheet\">"
     "/* vietnamese */
@font-face {
  font-family: 'League Spartan';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwvZ_LZmFDA.woff2) format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
  font-family: 'League Spartan';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwvZ-LZmFDA.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'League Spartan';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwvZwLZk.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
  font-family: 'Libre Baskerville';
  font-style: italic;
  font-weight: 400 700;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/librebaskerville/v24/kmKhZrc3Hgbbcjq75U4uslyuy4kn0qNcWx8QDP2V.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'Libre Baskerville';
  font-style: italic;
  font-weight: 400 700;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/librebaskerville/v24/kmKhZrc3Hgbbcjq75U4uslyuy4kn0qNcWxEQDA.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
  font-family: 'Libre Baskerville';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/librebaskerville/v24/kmKnZrc3Hgbbcjq75U4uslyuy4kn0qNXaxMICA.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'Libre Baskerville';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/librebaskerville/v24/kmKnZrc3Hgbbcjq75U4uslyuy4kn0qNZaxM.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
"
     "</style>"
     ;;      "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/katex.min.css\" integrity=\"sha384-u1zONI5gPXUx0UKI62c75/zww972y0v2rSK5ZYlVdS6xEuWDeZWUI66v6t1gvlXJ\" crossorigin=\"anonymous\">
;; <script src=\"https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/katex.min.js\" integrity=\"sha384-ykMNcWQhhTUb0YV9SPpPUFURHZ+tWmubkakGBP+OgNK/UXdO2gtzglWx0Rj9hnO3\" crossorigin=\"anonymous\"></script>
;; <script src=\"https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/contrib/auto-render.min.js\" integrity=\"sha384-bjyGPfbij8/NDKJhSGZNP/khQVgtHUE5exjm4Ydllo42FwIgYsdLO2lXGmRBf5Mz\" crossorigin=\"anonymous\"></script>
;; <script>
;;   document.addEventListener('DOMContentLoaded', function() {
;;     renderMathInElement(document.body, {
;;       macros: {
;;         \"\\\\arccot\": \"\\\\operatorname{arccot}\"
;;       }
;;     });
;;   });
;; </script>\n"
;;      "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/pseudocode@2.4.1/build/pseudocode.min.css\">
;; <script src=\"https://cdn.jsdelivr.net/npm/pseudocode@2.4.1/build/pseudocode.min.js\"></script>\n"
     )))
(defun org-html--tags (tags info)
  "Format TAGS into HTML.
INFO is a plist containing export options."
  (when tags
    (format "<span class=\"tag\">%s</span>"
                  (mapconcat
                   (lambda (tag)
                     (format "<span class=\"tag-%s\">%s</span>"
                                   (concat (plist-get info :html-tag-class-prefix)
                                                 (org-html-fix-class-name tag))
                                   tag))
                   tags "&nbsp;"))))

4.3. Postprocessing

(defun my/org-export-filetags-inject (text backend info)
  "Extract #+FILETAGS and inject an HTML string into the final output."
  (if (org-export-derived-backend-p backend 'html)
      (let* ((all-tags (org-node--get-filetags))
             (tags (seq-difference all-tags org-excluded-tags)))
        ;; Use org-node--get-filetags
        (if tags
            (let ((tags-html (concat "<div id=\"filetags\">\n"
                                     "<ul id=\"filetags-list\"><li id=\"filetags-text\">Tags:</li>"
                                     (mapconcat (lambda (tag)
                                                  (format "<li><a href=\"%s/tags/tag-%s.html\" class=\"tag\">#%s</a></li>"
                                                          org-publish-url tag tag))
                                                tags "\n")
                                     "</ul>"
                                     "</div>\n")))
              (replace-regexp-in-string "</h1>" (concat "</h1>\n" tags-html) text))
          text))
    text))
(defun my/org-export-html-link (text backend info)
  "Filter for HTML link export: strip the directory from `src' attributes."
  (when (org-export-derived-backend-p backend 'html)
    (let ((dir (expand-file-name (plist-get info :publishing-directory))))
      (replace-regexp-in-string
       "src=\".*\\(ltximg/org-tex-.*\\.svg\\)\""
       "src=\"\\1\""
       text
       t
       nil
       ))))
(setq org-export-filter-final-output-functions
      '(my/org-export-filetags-inject))
        ;; my/org-export-html-link

4.4. Template

(defun org-html--build-navbar (info)
  "Return navbar HTML for exported document.
INFO is a plist used as a communication channel."
  (let* ((file (file-name-base
                (plist-get info :input-file)))
         (dir (file-name-nondirectory
               (directory-file-name 
                (file-name-directory (plist-get info :input-file)))))
         (buttons '("home" "journal" "notes"))
         (buttons-html
          (mapconcat
           (lambda (button)
             (if (or (string-equal-ignore-case button file) (string-equal-ignore-case button dir))
                 (format "      <li class=\"navbar-active\">\n          <a class=\"navbar-link\" href=\"%s/%s.html\">%s</a>\n      </li>"
                         org-publish-url button (s-capitalize button))
               (format "      <li class=\"navbar-item\">\n          <a class=\"navbar-link\" href=\"%s/%s.html\">%s</a>\n      </li>"
                       org-publish-url button (s-capitalize button))))
           buttons "\n")))
    (concat
     (format "<div id=\"navbar\">
  <ul class=\"navbar-left\">
%s
  </ul>" buttons-html)
     (format "<ul class=\"navbar-right\">
      <li class=\"navbar-item\">
          <a class=\"navbar-link\" href=\"%s/search.html\">Search</a>
      </li>
  </ul>
</div>" org-publish-url))))
(defun org-html-toc (depth info &optional scope)
  "Build a table of contents.
DEPTH is an integer specifying the depth of the table.  INFO is
a plist used as a communication channel.  Optional argument SCOPE
is an element defining the scope of the table.  Return the table
of contents as a string, or nil if it is empty."
  (let ((toc-entries
               (mapcar (lambda (headline)
                               (cons (org-html--format-toc-headline headline info)
                                           (org-export-get-relative-level headline info)))
                             (org-export-collect-headlines info depth scope))))
    (when toc-entries
      (let* ((toc-id-counter (plist-get info :org-html--toc-counter))
             (toc (concat (format "<div id=\"text-table-of-contents%s\" role=\"doc-toc\">"
                                  (if toc-id-counter (format "-%d" toc-id-counter) ""))
                                            (org-html--toc-text toc-entries scope)
                                            "</div>\n")))
        (plist-put info :org-html--toc-counter (1+ (or toc-id-counter 0)))
              (if scope toc
          (concat (format "<details open id=\"table-of-contents%s\" role=\"doc-toc\">\n"
                          (if toc-id-counter (format "-%d" toc-id-counter) ""))
                  "<summary>"
                  (org-html--translate "Table of Contents" info)
                  "</summary>"
                              toc
                              "</details>\n"))))))
(defun org-html-inner-template (contents info)
  "Return body of document string after HTML conversion.
CONTENTS is the transcoded contents string.  INFO is a plist
holding export options."
  (concat
   ;; Document contents.
   contents
   ;; Footnotes section.
   (org-html-footnote-section info)))
(defun my/org-html--build-katex (plist)
  "Insert the user setup into the KaTeX template.
INFO is a plist used as a communication channel."
  (concat "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/katex.min.css\" integrity=\"sha384-u1zONI5gPXUx0UKI62c75/zww972y0v2rSK5ZYlVdS6xEuWDeZWUI66v6t1gvlXJ\" crossorigin=\"anonymous\">
  <script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/katex.min.js\" integrity=\"sha384-ykMNcWQhhTUb0YV9SPpPUFURHZ+tWmubkakGBP+OgNK/UXdO2gtzglWx0Rj9hnO3\" crossorigin=\"anonymous\"></script>
  <script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.18.4/dist/contrib/auto-render.min.js\" integrity=\"sha384-bjyGPfbij8/NDKJhSGZNP/khQVgtHUE5exjm4Ydllo42FwIgYsdLO2lXGmRBf5Mz\" crossorigin=\"anonymous\"></script>
  <script defer>
    document.addEventListener('DOMContentLoaded', function() {
      renderMathInElement(document.body, {
        macros: {
          \"\\\\arccot\": \"\\\\operatorname{arccot}\",
        }
      });
    });
  </script>\n"
          ))
(defun my/org-html-template (contents info)
  "Return complete document string after HTML conversion.
CONTENTS is the transcoded contents string.  INFO is a plist
holding export options."
  (concat
   (when (and (not (org-html-html5-p info)) (org-html-xhtml-p info))
     (let* ((xml-declaration (plist-get info :html-xml-declaration))
                  (decl (or (and (stringp xml-declaration) xml-declaration)
                                  (cdr (assoc (plist-get info :html-extension)
                                                          xml-declaration))
                                  (cdr (assoc "html" xml-declaration))
                                  "")))
       (when (not (or (not decl) (string= "" decl)))
               (format "%s\n"
                             (format decl
                                           (or (and org-html-coding-system
                                                          (coding-system-get org-html-coding-system :mime-charset))
                                               "iso-8859-1"))))))
   (org-html-doctype info)
   "\n"
   (concat "<html"
                 (cond ((org-html-xhtml-p info)
                              (format
                               " xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"%s\" xml:lang=\"%s\""
                               (plist-get info :language) (plist-get info :language)))
                             ((org-html-html5-p info)
                              (format " lang=\"%s\"" (plist-get info :language))))
                 ">\n")
   "<head>\n"
   (org-html--build-meta-info info)
   (org-html--build-head info)
   (my/org-html--build-katex info)
   "</head>\n"
   "<body>\n"
   (org-html--build-navbar info)
   (let ((link-up (org-trim (plist-get info :html-link-up)))
               (link-home (org-trim (plist-get info :html-link-home))))
     (unless (and (string= link-up "") (string= link-home ""))
       (format (plist-get info :html-home/up-format)
                     (or link-up link-home)
                     (or link-home link-up))))
   ;; Preamble.
   (org-html--build-pre/postamble 'preamble info)
   ;; Document contents.
   "<div id=\"content-container\">"
   (let ((div (assq 'content (plist-get info :html-divs))))
     (format "<%s id=\"%s\" class=\"%s\">\n"
             (nth 1 div)
             (nth 2 div)
             (plist-get info :html-content-class)))
   ;; Document title.
   (when (plist-get info :with-title)
     (let ((title (and (plist-get info :with-title)
                                   (plist-get info :title)))
                 (subtitle (plist-get info :subtitle))
                 (html5-fancy (org-html--html5-fancy-p info)))
       (when title
               (format
                (if html5-fancy
                    "<header>\n<h1 class=\"title\">%s</h1>\n%s</header>"
                  "<h1 class=\"title\">%s%s</h1>\n")
                (org-export-data title info)
                (if subtitle
                    (format
                     (if html5-fancy
                               "<p class=\"subtitle\" role=\"doc-subtitle\">%s</p>\n"
                             (concat "\n" (org-html-close-tag "br" nil info) "\n"
                                           "<span class=\"subtitle\">%s</span>\n"))
                     (org-export-data subtitle info))
            "")))))
   ;; Table of contents.
   (let ((depth (plist-get info :with-toc)))
     (when depth (org-html-toc depth info)))
   contents
   (format "</%s>\n" (nth 1 (assq 'content (plist-get info :html-divs))))
   "</div>"
   ;; Postamble.
   "<div id=\"postamble-container\">"
   (org-html--build-pre/postamble 'postamble info)
   "</div>"
   ;; Possibly use the Klipse library live code blocks.
   (when (plist-get info :html-klipsify-src)
     (concat "<script>" (plist-get info :html-klipse-selection-script)
                   "</script><script src=\""
                   org-html-klipse-js
                   "\"></script><link rel=\"stylesheet\" type=\"text/css\" href=\""
                   org-html-klipse-css "\"/>"))
   ;; Spacer to prevent view jumping
   "<div id=\"spacer\" style=\"height:50vh;\"></div>"
   "<div id=\"folding\">
      <div id=\"statePills\" class=\"pill-group\">
        <span class=\"pill\" data-state=\"overview\">Overview</span>
        <span class=\"pill\" data-state=\"contents\">Contents</span>
        <span class=\"pill pill-active\" data-state=\"show-all\">Show All</span>
     </div>
   </div>\n"
   ;; <button id=\"cycleBtn\">Cycle visibility (Overview)</button>
   ;; Closing document.
   "</body>\n</html>\n"))
(defun my/org-html-headline-transcoder (headline contents info)
  "Transcode level 1 HEADLINE into a details/summary block."
  (let ((level (1+ (org-element-property :level headline))))
    ;; Check if it is a top-level (first level) heading
    (let* ((title (org-export-data (org-element-property :title headline) info))
           (tags (and (plist-get info :with-tags)
                      (org-export-get-tags headline info)))
           (ftags (concat "["
                          (mapconcat
                           (lambda (tag)
                             (format "%s" tag))
                           tags ", ")
                          "]")))
      (if (equal tags nil)
          (format "\n<details class=\"org-details\" open>\n<summary class=\"org-summary\"><h%d>%s</h%d></summary>\n%s\n</details>\n" 
                  level title level (or contents ""))
        (format "\n<details class=\"org-details\" open>\n<summary class=\"org-summary\"><span class=\"org-summary-content\"><h%d>%s</h%d></span><span class=\"org-summary-tags\">%s</span></summary>\n%s\n</details>\n" 
                level title level ftags (or contents ""))))))
(defun my/org-html-headline (headline contents info)
  "Transcode a HEADLINE element from Org to HTML.
CONTENTS holds the contents of the headline.  INFO is a plist
holding contextual information."
  (unless (org-element-property :footnote-section-p headline)
    (let* ((numberedp (org-export-numbered-headline-p headline info))
           (numbers (org-export-get-headline-number headline info))
           (level (+ (org-export-get-relative-level headline info)
                     (1- (plist-get info :html-toplevel-hlevel))))
           (todo (and (plist-get info :with-todo-keywords)
                      (let ((todo (org-element-property :todo-keyword headline)))
                        (and todo (org-export-data todo info)))))
           (todo-type (and todo (org-element-property :todo-type headline)))
           (priority (and (plist-get info :with-priority)
                          (org-element-property :priority headline)))
           (text (org-export-data (org-element-property :title headline) info))
           (tags (and (plist-get info :with-tags)
                      (org-export-get-tags headline info)))
           (full-text (funcall (plist-get info :html-format-headline-function)
                               todo todo-type priority text tags info))
           (contents (or contents ""))
                 (id (org-html--reference headline info))
                 (formatted-text
                  (if (plist-get info :html-self-link-headlines)
                            (format "<a href=\"#%s\">%s</a>" id full-text)
                    full-text)))
      (if (org-export-low-level-p headline info)
          ;; This is a deep sub-tree: export it as a list item.
          (let* ((html-type (if numberedp "ol" "ul")))
                  (concat
                   (and (org-export-first-sibling-p headline info)
                              (apply #'format "<%s class=\"org-%s\">\n"
                                           (make-list 2 html-type)))
                   (org-html-format-list-item
                    contents (if numberedp 'ordered 'unordered)
                    nil info nil
                    (concat (org-html--anchor id nil nil info) formatted-text)) "\n"
                   (and (org-export-last-sibling-p headline info)
                              (format "</%s>\n" html-type))))
              ;; Standard headline.  Export it as a section.
        (let ((extra-class
                     (org-element-property :HTML_CONTAINER_CLASS headline))
                    (headline-class
                     (org-element-property :HTML_HEADLINE_CLASS headline))
              (first-content (car (org-element-contents headline))))
          (format "<details id=\"%s\" class=\"%s\" open>%s%s</details>\n"
                  (format "outline-container-%s" id)
                  (concat (format "outline-%d" level)
                          (and extra-class " ")
                          extra-class)
                  (format "\n<summary id=\"%s\"%s>%s</summary>\n"
                          id
                                            (if (not headline-class) ""
                                              (format " class=\"%s\"" headline-class))
                          (concat
                           (and numberedp
                                (format
                                 "<span class=\"section-number-%d\">%s</span> "
                                 level
                                 (concat (mapconcat #'number-to-string numbers ".") ".")))
                           formatted-text))
                  ;; When there is no section, pretend there is an
                  ;; empty one to get the correct <div
                  ;; class="outline-...> which is needed by
                  ;; `org-info.js'.
                  (if (org-element-type-p first-content 'section) contents
                    (concat (org-html-section first-content "" info) contents))))))))
(org-export-define-derived-backend 'my/html 'html
  :translate-alist '(;; (headline . my/org-html-headline)
                     (template . my/org-html-template)))
(defun my/org-html-publish-to-html (plist filename pub-dir)
  "Publish an org file to HTML.

FILENAME is the filename of the Org file to be published.  PLIST
is the property list for the given project.  PUB-DIR is the
publishing directory.

Return output file name."
  (org-publish-org-to 'my/html filename
                                  (concat (when (> (length org-html-extension) 0) ".")
                                                (or (plist-get plist :html-extension)
                                                          org-html-extension
                                                          "html"))
                                  plist pub-dir))

4.5. Journal-specific export

(defun my/org-html-journal-headline-transcoder (headline contents info)
  "Transcode level 1 HEADLINE into a details/summary block."
  (let ((level (org-element-property :level headline)))
    ;; Check if it is a top-level (first level) heading
    (if (= level 1)
        (let* ((title (org-export-data (org-element-property :title headline) info))
               (tags (and (plist-get info :with-tags)
                          (org-export-get-tags headline info)))
               (ftags (concat "["
                              (mapconcat
                               (lambda (tag)
                                 (format "%s" tag))
                               tags ", ")
                              "]")))
          (if (equal tags nil)
              (format "\n<details class=\"org-details\" open>\n<summary class=\"org-summary\">%s</summary>\n%s\n</details>\n" 
                      title (or contents ""))
            (format "\n<details class=\"org-details\" open>\n<summary class=\"org-summary\"><span class=\"org-summary-content\">%s</span><span class=\"org-summary-tags\">%s</span></summary>\n%s\n</details>\n" 
                    title ftags (or contents ""))))
      ;; Fall back to standard HTML export behavior for levels 2+
      (org-export-with-backend 'html headline contents info))))
(org-export-define-derived-backend 'my/html-journal 'html
  :translate-alist '((headline . my/org-html-journal-headline-transcoder)
                     (template . my/org-html-template)))
(defun my/org-html-publish-journal-to-html (plist filename pub-dir)
  "Publish an org file to HTML.

FILENAME is the filename of the Org file to be published.  PLIST
is the property list for the given project.  PUB-DIR is the
publishing directory.

Return output file name."
  (org-publish-org-to 'my/html-journal filename
                                  (concat (when (> (length org-html-extension) 0) ".")
                                                (or (plist-get plist :html-extension)
                                                          org-html-extension
                                                          "html"))
                                  plist pub-dir))

4.6. org-node backlink sync

(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
          ((symbol-function 'y-or-n-p)   (lambda (&rest _) t))
          (use-dialog-box nil)
          (inhibit-message t)) ; Optional: suppresses annoying output
  (ignore-error user-error
    (org-node-backlink-mass-update-drawers)))
(org-save-all-org-buffers)
(defun my/org-mem-export-compressed-json (output-file)
  "Dump `org-mem` structures into a lean JSON file for client-side web search."
  (interactive "fSave JSON to: ")
  (let ((nodes '()))
    (maphash
     (lambda (id entry)
       (push `((id . ,(or id ""))
               (title  . ,(or (org-mem-entry-title entry) ""))
               (file  . ,(or (string-replace
                              ".org"
                              ".html"
                              (string-replace
                               "~/giabao.xyz/org" org-publish-url
                               (org-mem-entry-file entry))) ""))
               (tags  . ,(vconcat (or (org-mem-entry-tags entry) '()))))
             nodes))
     org-mem--id<>entry)
    (with-temp-file output-file
      (insert (json-encode nodes)))))

(my/org-mem-export-compressed-json (expand-file-name "~/giabao.xyz/data/org-mem.json"))

4.7. Project publish

(setq org-publish-project-alist
      `(("css"
         :base-directory "css/"
         :base-extension "css"
         :publishing-directory "html/css/"
         :recursive t
         :publishing-function org-publish-attachment)
        ("js"
         :base-directory "js/"
         :base-extension "js"
         :publishing-directory "html/js/"
         :recursive t
         :publishing-function org-publish-attachment)
        ("data"
         :base-directory "data/"
         :base-extension "json"
         :publishing-directory "html/data/"
         :recursive t
         :publishing-function org-publish-attachment)
        ("img"
         :base-directory "img/"
         :base-extension "png"
         :publishing-directory "html/img/"
         :recursive t
         :publishing-function org-publish-attachment)
        ("home and notes"
         :base-directory "org/"
         :base-extension "org"
         :publishing-directory "html/"
         :exclude ,(regexp-opt '("journal.org"))
         :recursive nil
         :publishing-function my/org-html-publish-to-html
         :html-head my/org-html--build-head
         :auto-sitemap nil)
        ("journal"
         :base-directory "org/"
         :base-extension "org"
         :publishing-directory "html/"
         :exclude ,(regexp-opt '("home.org" "notes.org"))
         :recursive nil
         :publishing-function my/org-html-publish-journal-to-html
         :html-head my/org-html--build-head
         :auto-sitemap nil)
        ("notes"
         :base-directory "org/notes/"
         :base-extension "org"
         :publishing-directory "html/notes/"
         :recursive nil
         :publishing-function my/org-html-publish-to-html
         :html-head my/org-html--build-head
         :auto-sitemap nil)
        ("all" :components ("home and notes"
                            "css" "js" "data" "img"
                            "journal"
                            "notes"))))

5. Uploading

rsync -rtv --no-g --delete /home/bao/giabao.xyz/html/* giabao.xyz:/var/www/html/giabao.xyz/
rsync -rtv --no-g --delete /home/bao/giabao.xyz/img/ bao@giabao.xyz:/var/www/resources/giabao.xyz/img/
rsync -rtv --no-g --delete /home/bao/giabao.xyz/data/ bao@giabao.xyz:/var/www/html/giabao.xyz/data/
rsync -rtv --no-g --delete /home/bao/giabao.xyz/js/ bao@giabao.xyz:/var/www/html/giabao.xyz/js/
rsync -rtv --no-g --delete /home/bao/giabao.xyz/css/ bao@giabao.xyz:/var/www/html/giabao.xyz/css/
rsync -rtv --no-g /home/bao/giabao.xyz/img/ giabao.xyz:/var/www/resources/img/
rsync -rtv --no-g /home/bao/giabao.xyz/css/ giabao.xyz:/var/www/html/giabao.xyz/
rsync -rtv --no-g /home/bao/giabao.xyz/js/ giabao.xyz:/var/www/html/giabao.xyz/
rsync -rtv --no-g /home/bao/giabao.xyz/data/ giabao.xyz:/var/www/html/giabao.xyz/data/

Created: 2026-09-11 Fri 20:51

Validate

Overview Contents Show All