Publishing with org-mode

Intended for use with: Emacs configuration for writing.

(setq package-user-dir (expand-file-name "~/.config/emacs-writing/elpa"))
(package-initialize)
(require 's)
(require 'org)
(require 'ox)
(require 'ox-publish)
(require 'htmlize)
(setq org-html-htmlize-output-type 'css
      org-html-head-include-default-style nil)
(require 'org-mem)
(setq org-mem-watch-dirs '("~/giabao.xyz/org/"))
(setq 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" "tag"))
(setq org-html-mathjax-template "")
(setq org-link-abbrev-alist
      '(("img" . "https://giabao.xyz/img/%s")
        ("vid" . "https://giabao.xyz/vid/%s")))

2. org-publish

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

(setq org-publish-dir "/home/bao/giabao.xyz/")
(setq org-publish-url "https://giabao.xyz")

2.1. Preprocessing

(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))))))
(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)))))))))
(setq org-export-before-processing-functions
      '(my/org-preprocess-block-title
        my/org-export-format-backlinks-drawer))

2.2. Content processing

(defun my/org-html--build-head (plist)
  (let* ((dir (expand-file-name (plist-get plist :base-directory)))
         (org-css (file-relative-name "css/org.css" dir))
         (math-css (file-relative-name "css/math.css" dir)))
    (concat (format "<link rel=\"stylesheet\" href=\"%s\" type=\"text/css\" />
<link rel=\"stylesheet\" href=\"%s\" type=\"text/css\" />\n" org-css math-css)
            "<link rel=\"preload\" as=\"image\" href=\"https://giabao.xyz/img/background.gif\" fetchpriority=\"high\">
<link rel=\"preload\" as=\"image\" href=\"https://giabao.xyz/img/border.png\" fetchpriority=\"high\">
<link rel=\"preload\" as=\"image\" href=\"https://giabao.xyz/img/journal/grid.png\" fetchpriority=\"high\">
<link rel=\"preload\" as=\"image\" href=\"https://giabao.xyz/img/img-border.gif\" fetchpriority=\"high\">
"
            "<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:ital,wght@0,100..900;1,100..900&display=swap\" rel=\"stylesheet\">
"
;;             "<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=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&display=swap\" rel=\"stylesheet\">
;; "
            "<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>
  document.addEventListener('DOMContentLoaded', function() {
    renderMathInElement(document.body, {
      macros: {
        \"\\\\arccot\": \"\\\\operatorname{arccot}\"
      }
    });
  });
</script>
")))
(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;"))))

2.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))
(setq org-export-filter-final-output-functions
      '(my/org-export-filetags-inject))

2.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/tags.html\">Tags</a>
      </li>
  </ul>
</div>" org-publish-url))))
(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)
   (org-html--build-mathjax-config 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))
            "")))))
   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 "\"/>"))
   ;; Closing document.
   "</body>\n</html>"))
(org-export-define-derived-backend 'my/html 'html
  :translate-alist '((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))

2.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)))
          (format "\n<details class=\"org-details\">\n<summary class=\"org-summary\">%s</summary>\n%s\n</details>\n" 
                  title (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/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))

2.6. org-node backlink sync

(ignore-error user-error
  (org-node-backlink-mass-update-drawers))
(org-save-all-org-buffers)

2.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)
        ("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"
                            "journal"
                            "notes"))))

3. 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/ giabao.xyz:/var/www/html/giabao.xyz/

Created: 2026-08-23 Sun 15:23

Validate