andersch.dev

<2024-06-02>

The Publishing Script of this Website

Using org-mode to write a publishing script as a literate program

This page is the HTML exported version of the script that generates this website. The script is written in emacs-lisp, but is using org-mode to write it out as a literate program. When generating, all emacs-lisp source code that you see on this page is tangled from the original org-file to a file called publish.el.

Packages

;; set package install dir to local directory
(require 'package)
(setq package-user-dir (expand-file-name "./.packages"))
(setq package-archives '(("melpa" . "https://melpa.org/packages/")
                         ("elpa" . "https://elpa.gnu.org/packages/")
                         ("nongnu" . "https://elpa.nongnu.org/nongnu/")))
(package-initialize)
(unless package-archive-contents (package-refresh-contents))

Dependencies

(package-install 'htmlize) ; enable export of #+begin_src code blocks

(require 'ox-publish)
(require 'org)

(require 'cl-lib)

; for :IGNORE: headlines
(package-install 'org-contrib) ; for ox-extra
(require 'ox-extra)
(ox-extras-activate '(latex-header-blocks ignore-headlines))

(require 'ob-emacs-lisp)

(add-to-list 'load-path "~/dev/org-slide")
(require 'org-slide)

Configuration

(setq
      keywords '("TITLE" "DATE" "DESCRIPTION" "IMAGE" "TAGS[]") ; keywords to parse from .org files
      org-html-htmlize-output-type 'css
      org-export-allow-bind-keywords t ; Allows #+BIND: in a buffer
      org-confirm-babel-evaluate nil   ; needed to enable org-babel src-block execution from a script
      org-src-fontify-natively t)

; set footnotes to be h3, everything else is default
(setq org-html-footnotes-section
      "<div id=\"footnotes\">\n<h3 class=\"footnotes\">%s: </h3>\n<div id=\"text-footnotes\">\n%s\n</div>\n</div>")

Helper functions

(defun get-org-files (directory)
  "Return a list of .org files in DIRECTORY excluding 'index.org'."
  (cl-remove-if
   (lambda (file) (string-equal (concat directory "/" "index.org") file))
   (directory-files-recursively directory "\\.org$")))

(defun get-org-file-keywords (file)
  (with-temp-buffer
    (insert-file-contents file)
    (list file (org-collect-keywords keywords))))

(defun sort-keyword-list-by-date (keyword-list &optional new-to-old)
  "Sort the list by the date value of the form <YYYY-MM-DD HH:MM>"
  (sort keyword-list
        (lambda (a b)
          (let* ((date-str-a (replace-regexp-in-string "<\\[\\]>" "" (cadr (assoc "DATE" (cadr a)))))
                 (date-str-b (replace-regexp-in-string "<\\[\\]>" "" (cadr (assoc "DATE" (cadr b))))))
            (if new-to-old
              (string> date-str-a date-str-b)
              (string< date-str-a date-str-b))))))

(defun article-marked-for-noexport-p (article)
  (string-match-p (regexp-quote "noexport") (cadr (assoc "TAGS[]" (cadr article)))))

Filter out links to index.html

Replace links to e.g. "article/index.html" to just "article".

(defun filter-out-index-html (transcoded-data-string backend communication-channel-plist)
  (when (org-export-derived-backend-p backend 'html)
    ; NOTE "/index.html" doesn't get replaced in case of internal links for some reason...
    (string-replace "index.html" ""
                    (string-replace "/index.html" "" (substring-no-properties transcoded-data-string)))))

(add-to-list 'org-export-filter-link-functions 'filter-out-index-html)

Filter out auto-generated org-ids

Normally org-mode randomly generates an org-id for every heading and uses those links, which causes a bunch of noise in commits. This filter strips out all org-ids from exported html files. We can instead use the :CUSTOM_ID: property for a heading that we want to link to.

(defun html-body-id-filter (output backend info)
  "Remove random ID attributes generated by Org."
  (when (eq backend 'html)
    (replace-regexp-in-string " id=\"[[:alpha:]-]*org[[:alnum:]]\\{7\\}\"" "" output t)))

(add-to-list 'org-export-filter-final-output-functions 'html-body-id-filter)

Comment Section

Adds a GitHub Issues-based comment section using utterances. Only applies to articles that are marked with the keyword #+COMMENTS: t.

Loading animation script

Following javascript animates the comment section title until the lazy-loaded comment section has loaded in.

const commentSectionTitle = document.getElementById('comment-section-title');
const commentsDiv         = document.getElementById('comment-section');
commentSectionTitle.style.animation = 'loading 0.6s infinite alternate';
document.addEventListener('DOMContentLoaded', function() {
  const observer = new MutationObserver(function(mutationsList) {
    for (let mutation of mutationsList) {
      if (mutation.type === 'childList') {
        for (let node of mutation.addedNodes) {
          if (node.nodeName === 'DIV') {
            for (const child of node.children) {
              if (child.tagName === 'IFRAME') {
                child.addEventListener('load', function() {
                  commentSectionTitle.style.animation = 'none';
                });
              }
            }
          }
        }
      }
    }
  });

  observer.observe(commentsDiv, { childList: true });
});

HTML Layout

Note the usage of single quotes instead of double quotes for attribute values. This way we can use noweb to include the html without having to escape strings.

<hr>
<div id='comment-section'>
<h3 id='comment-section-title'>Comments</h3>
<script src='https://utteranc.es/client.js'
       repo='dandersch/andersch.dev'
       issue-term='pathname'
       label='.💬'
       theme='photon-dark'
       crossorigin='anonymous'
       async>
</script>
<script type='text/javascript'>
<<comment-loading-animation-js>>
</script>
</div>

Elisp variable

(setq comment-section-html "<<comment-section-html>>" )

Footnotes Section workaround

If we include above HTML at the very end of an org-file using #+BEGIN_EXPORT html, org-mode will still append the footnotes section below that (if the article ever used [fn::footnote text]). As a workaround, we define below function to later add as a filter-hook to insert the HTML at the very end.

; needed because otherwise footnotes will be below the comment section
(defun insert-comment-section  (contents html-backend info)
  (when (string-match "</main>" contents)
    (replace-match (concat comment-section-html "</main>") t t contents 0)))

Keyword Lists

Fills the primary datastructure of this script of the form:

("article.org" (("TITLE" "Article Title") ("TAGS" "tag1 tag2")))

; TODO put together
(setq article-keyword-list '())
(setq project-keyword-list '())
(setq other-keyword-list   '())

;(setq keyword-list
;  '(
;     ("article" '("article.org" (("TITLE" "Article Title") ("TAGS" "tag1 tag2"))))
;     ("project" '("project1.org" (("TITLE" "Article Title") ("TAGS" "tag1 tag2"))))

; (cadr (assoc "TITLE" (cadr (assoc "article" article)))
;                  (print
; FILL & SORT KEYWORD-LISTS FOR PROJECT/, ARTICLE/, OTHER/
(defun fill-keyword-lists ()
  (dolist (article (get-org-files "article"))
    (let ((article-keywords (get-org-file-keywords article)))
      (unless (article-marked-for-noexport-p article-keywords)
        (push (get-org-file-keywords article) article-keyword-list))))
  (setq article-keyword-list (sort-keyword-list-by-date article-keyword-list t))

  (dolist (project (get-org-files "project"))
    (let ((project-keywords (get-org-file-keywords project)))
      (unless (article-marked-for-noexport-p project-keywords)
        (push (get-org-file-keywords project) project-keyword-list))))
  (setq project-keyword-list (sort-keyword-list-by-date project-keyword-list t))

  (dolist (other (get-org-files "other"))
    (let ((other-keywords (get-org-file-keywords other)))
      (unless (article-marked-for-noexport-p other-keywords)
        (push (get-org-file-keywords other) other-keyword-list))))
  (setq other-keyword-list (sort-keyword-list-by-date other-keyword-list t))

  ; article-keyword-list == (cdr (assoc "article" keyword-list))
  (setq keyword-list `(,(cons "article" article-keyword-list) ,(cons "project" project-keyword-list) ,(cons "other" other-keyword-list))))

RSS Feed Generation

Generates a simple rss feed for articles specifically.

(defun generate-main-rss-feed ()
  ; rss header, check with  https://validator.w3.org/feed/
  (with-temp-file "feed.xml"
    (insert
     (let* ((website-title "andersch.dev")
            (homepage      "https://andersch.dev")
            (rss-filepath  "/feed.xml"))
     (concat "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
             "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n"
             "<channel>\n"
             (format "<title>%s</title>\n" website-title)
             "<!-- <lastBuildDate>Wed, 15 Dec 2021 00:00:00 +0000</lastBuildDate> -->\n" ; TODO insert todays date
             (format "<atom:link href=\"%s%s\" rel=\"self\" type=\"application/rss+xml\"/>\n" homepage rss-filepath)
             (format "<link>%s</link>\n" homepage)
             "<description>Stuff on programming</description>\n"
             "<language>en-us</language>\n"))))
  ; rss entries
  (dolist (article article-keyword-list)
    (write-region
      (format
         (concat "<item>\n"
                 "<title>%s</title>\n"
                 "<link>%s</link>\n"
                 "<guid>%s</guid>\n"
                 "<description>\n"
                 "&lt;p&gt;%s&lt;/p&gt;\n"
                 "&lt;img src=\"https://andersch.dev/%s\"/&gt;\n"
                 "</description>\n"
                 "<pubDate>%s</pubDate>\n</item>\n")
            (cadr (assoc "TITLE" (cadr article)))
            (concat "https://andersch.dev/" (string-replace "/index.org" "" (car article)))
            (concat "https://andersch.dev/" (string-replace "/index.org" "" (car article)))
            (cadr (assoc "DESCRIPTION" (cadr article)))
            (concat (string-replace "index.org" "" (car article)) (cadr (assoc "IMAGE" (cadr article))))
            (format-time-string "%a, %d %b %Y %H:%M:%S %z" (seconds-to-time (org-time-string-to-time (cadr (assoc "DATE" (cadr article))))))
            )
      nil "feed.xml" 'append))
  ; rss ending
  (write-region "</channel>\n</rss>" nil "feed.xml" 'append))

Tagging System

Generates a tag.org for every unique tag across all articles.

(defun generate-tag-files ()

  ; collect all tags
  (setq article-tags '())
  (dolist (article article-keyword-list)
     (setq article-tags (append (split-string (cadr (assoc "TAGS[]" (cadr article)))  " +") article-tags)))
  (delete-dups article-tags)

  (setq project-tags '())
  (dolist (project project-keyword-list)
     (setq project-tags (append (split-string (cadr (assoc "TAGS[]" (cadr project)))  " +") project-tags)))
  (delete-dups project-tags)

  (setq other-tags '())
  (dolist (other other-keyword-list)
     (setq other-tags (append (split-string (cadr (assoc "TAGS[]" (cadr other)))  " +") other-tags)))
  (delete-dups other-tags)

  (setq all-tags '())
  (setq all-tags (cl-concatenate 'list article-tags project-tags other-tags))
  (delete-dups all-tags)

  ; generate .org files for all tags
  (dolist (tag all-tags)
    (with-temp-file (format "tag/%s.org" tag)
      (insert (format "#+TITLE: Pages tagged %s\n" tag))))

  ; append "* Articles" headline
  (dolist (tag article-tags)
    (write-region (format "* Articles tagged ~%s~\n" tag) nil (format "tag/%s.org" tag) 'append))
  ; add entry of an article to its tag.org's
  (dolist (article article-keyword-list)
    (dolist (tag (split-string (cadr (assoc "TAGS[]" (cadr article)))  " +"))
      (write-region (format "- [[../%s][%s]]\n" (car article) (cadr (assoc "TITLE" (cadr article))))
                    nil (format "tag/%s.org" tag) 'append)))

  ; append "* Projects" headline
  (dolist (tag project-tags)
    (write-region (format "* Projects tagged ~%s~\n" tag) nil (format "tag/%s.org" tag) 'append))
  ; add entry of a project to its tag.org's
  (dolist (project project-keyword-list)
    (dolist (tag (split-string (cadr (assoc "TAGS[]" (cadr project)))  " +"))
      (write-region (format "- [[../%s][%s]]\n" (car project) (cadr (assoc "TITLE" (cadr project))))
                    nil (format "tag/%s.org" tag) 'append)))

  ; append "* Other" headline
  (dolist (tag other-tags)
    (write-region (format "* Other tagged ~%s~\n" tag) nil (format "tag/%s.org" tag) 'append))
  ; add entry of a project to its tag.org's
  (dolist (other other-keyword-list)
    (dolist (tag (split-string (cadr (assoc "TAGS[]" (cadr other)))  " +"))
      (write-region (format "- [[../%s][%s]]\n" (car other) (cadr (assoc "TITLE" (cadr other))))
                    nil (format "tag/%s.org" tag) 'append))))

Prepare publishing function

Gets called by org-publish before the main publishing step.

(defun prepare-publishing (project-properties)
  (fill-keyword-lists)
  (generate-main-rss-feed)
  (generate-tag-files))

Org-publish Customization

See here for exporter-specific properties and use (describe-variable 'org-publish-project-alist) for documentation on general options.

(setq org-publish-project-alist
      (list
       (list "andersch.dev"
             :recursive            t
             :base-directory       "./"
             :publishing-directory "./"
             :publishing-function  'org-html-publish-to-html    ;; may be a list of functions
             :preparation-function 'prepare-publishing          ;; called before publishing
           ; :completion-function                               ;; called afterwards
           ; :base-extension                                    ;; extension of source files
           ; :html-extension       ""                           ;; extension of generated html files (without dot)
             :exclude  (regexp-opt '("code.org" "publish.org")) ;; regex of files to exclude
           ; :include                                           ;; list of files to include

           ; :html-doctype "html5"                              ;; default is "xhtml-strict"
             :html-divs            '((preamble "header" "top")
                                     (content "main" "content")
                                     (postamble "footer" "postamble"))
             :html-html5-fancy     t
             :html-head            (concat "<title>andersch.dev</title>\n"
                                           "<link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n"
                                           "<link rel=\"stylesheet\" href=\"/style.css\">\n"
                                           ; NOTE import ubuntu font for now
                                           "<link rel=\"stylesheet\" type=\"text/css\" href=\"https://fonts.googleapis.com/css?family=Ubuntu:regular,bold&subset=Latin\">"
                                           )
             :html-preamble        t
             :html-preamble-format `(("en" ,(with-temp-buffer (insert-file-contents "header.html") (buffer-string))))
             :html-postamble       nil                       ;; don't insert a footer with a date etc.

             :auto-sitemap         nil                       ;; https://orgmode.org/manual/Site-map.html
           ; :sitemap-filename     "sitemap.org"
           ; :sitemap-title
           ; :sitemap-style        'tree                     ;; list or tree
           ; :sitemap-sort-files   'anti-chronologically

             :makeindex            nil                       ;; https://orgmode.org/manual/Generating-an-index.html
             :with-title           nil                       ;; we include our own header
             :with-author          nil
             :with-creator         nil                       ;; don't include emacs and org versions in footer
             :with-toc             nil                       ;; no table of contents
             :section-numbers      nil                       ;; no section numbers for headings
             :html-validation-link nil                       ;; don't show validation link
             :time-stamp-file      nil                       ;; don't include "Created: <timestamp>" in footer
             :with-date            nil)))

Fix caching issue

; NOTE caching causes problems with updating titles etc., so we reset the cache before publishing
(setq org-publish-use-timestamps-flag nil)
(setq org-publish-timestamp-directory "./.org-timestamps/")
(org-publish-remove-all-timestamps)

Workaround

; NOTE workaround to not get a "Symbol’s function definition is void" error when publishing
(defun get-article-keyword-list () article-keyword-list) ; NOTE workaround to pass keyword-list to a source-block in an org file
(defun get-project-keyword-list () project-keyword-list) ; NOTE workaround to pass keyword-list to a source-block in an org file
(defun get-other-keyword-list   () other-keyword-list)   ; NOTE workaround to pass keyword-list to a source-block in an org file

Build

(org-publish "andersch.dev" t)
(message "Build complete")

Code snippets

Helper functions

(defun format-entry-as-image-link (entry type) ; of the form ("article.org" (("TITLE" "Article Title") ("TAGS" "tag1 tag2")))
  (format
   (concat
     "<div class=\"image-container\">\n"
        "<a href=\"./%s\">\n"
            "<div class=\"overlay\">\n"
                "<div class=\"title\">%s</div>\n"
                "<div class=\"description\">%s</div>\n"
            "</div>\n"
            "<img src=\"./%s/%s\" alt=\"\">\n"
        "</a>\n"
     "</div>\n")
     (string-replace "/index.org" "" (car entry))
     (cadr (assoc "TITLE" (cadr entry)))
     (cadr (assoc "DESCRIPTION" (cadr entry)))
     ;type
     (string-replace "/index.org" "" (car entry))
     (cadr (assoc "IMAGE" (cadr entry)))))

TODO Generate "Latest Articles/Projects" View

Doesn't get "nowebbed" into the source codes after

#+NAME: latest-article
#+BEGIN_SRC emacs-lisp :eval no :exports results :results raw drawer :var list='(get-article-keyword-list) :noweb yes
(defun format-entry-as-image-link (entry type) ; of the form ("article.org" (("TITLE" "Article Title") ("TAGS" "tag1 tag2")))
  (format
   (concat
     "<div class=\"image-container\">\n"
        "<a href=\"./%s\">\n"
            "<div class=\"overlay\">\n"
                "<div class=\"title\">%s</div>\n"
                "<div class=\"description\">%s</div>\n"
            "</div>\n"
            "<img src=\"./%s/%s\" alt=\"\">\n"
        "</a>\n"
     "</div>\n")
     (string-replace "/index.org" "" (car entry))
     (cadr (assoc "TITLE" (cadr entry)))
     (cadr (assoc "DESCRIPTION" (cadr entry)))
     ;type
     (string-replace "/index.org" "" (car entry))
     (cadr (assoc "IMAGE" (cadr entry)))))

(setq latest (car list))

(setq articles-as-images "")
(dolist (article list)
  (setq articles-as-images (concat articles-as-images (format-entry-as-image-link article "article"))))

(if (eq org-export-current-backend 'html)
  (concat "#+BEGIN_EXPORT html\n"
          articles-as-images
          "#+END_EXPORT\n")
  (format "Latest article: [[./%s][%s]]\n#+attr_html: :width 700px\n[[./article/%s]]\n"
          (car latest)
          (cadr (assoc "TITLE" (cadr latest)))
          (cadr (assoc "IMAGE" (cadr latest)))))
#+END_SRC
#+NAME: latest-project
#+BEGIN_SRC emacs-lisp :eval no :exports results :results raw drawer :var list=(get-project-keyword-list) :noweb yes
(defun format-entry-as-image-link (entry type) ; of the form ("article.org" (("TITLE" "Article Title") ("TAGS" "tag1 tag2")))
  (format
   (concat
     "<div class=\"image-container\">\n"
        "<a href=\"./%s\">\n"
            "<div class=\"overlay\">\n"
                "<div class=\"title\">%s</div>\n"
                "<div class=\"description\">%s</div>\n"
            "</div>\n"
            "<img src=\"./%s/%s\" alt=\"\">\n"
        "</a>\n"
     "</div>\n")
     (string-replace "/index.org" "" (car entry))
     (cadr (assoc "TITLE" (cadr entry)))
     (cadr (assoc "DESCRIPTION" (cadr entry)))
     ;type
     (string-replace "/index.org" "" (car entry))
     (cadr (assoc "IMAGE" (cadr entry)))))

(setq latest (car list))

(setq articles-as-images "")
(dolist (article list)
  (setq articles-as-images (concat articles-as-images (format-entry-as-image-link article "project"))))

(if (eq org-export-current-backend 'html)
  (concat "#+BEGIN_EXPORT html\n"
          articles-as-images
          "#+END_EXPORT\n")
  ; else
  (format "Latest project: [[./%s][%s]]\n[[./project/%s]]\n" (car latest) (cadr (assoc "TITLE" (cadr latest))) (cadr (assoc "IMAGE" (cadr latest)))))
#+END_SRC

Generate Tags

#+NAME: generate-tags
#+BEGIN_SRC emacs-lisp :eval no :exports results :results html
(setq tags-string '())
(if (eq org-export-current-backend 'html)
  (progn
    (setq tags-string (append tags-string (list "<div class=\"tags\">")))
    (setq tags-string (append tags-string (list "[ ")))
    (setq tags (split-string (cadar (org-collect-keywords '("TAGS[]"))) " +"))
    (dolist (tag tags)
      (setq tags-string (append tags-string (list (format "<a href=\"/tag/%s.html\">%s</a> " tag tag)))))
    (setq tags-string (append tags-string (list "]")))
    (setq tags-string (append tags-string (list "</div>\n")))
    (mapconcat #'identity tags-string "")) ; flatten string list to a string
  (print ""))
#+END_SRC

Generate Article Header & Subtitle

#+NAME: generate-article-header
#+BEGIN_SRC emacs-lisp :eval no :exports results :results html
(defun generate-tags ()
  (setq tags-string '())
      (setq tags-string (append tags-string (list "<div class=\"tags\">")))
      (setq tags-string (append tags-string (list "<code>")))
      (setq tags-string (append tags-string (list "[ ")))
      (setq tags (split-string (cadar (org-collect-keywords '("TAGS[]"))) " +"))
      (dolist (tag tags)
        (setq tags-string (append tags-string (list (format "<a href=\"/tag/%s.html\">%s</a> " tag tag)))))
      (setq tags-string (append tags-string (list "]")))
      (setq tags-string (append tags-string (list "</code>")))
      (setq tags-string (append tags-string (list "</div>\n")))
      (mapconcat #'identity tags-string "")) ; flatten string list to a string

(setq keywords (org-collect-keywords '("TITLE" "DESCRIPTION" "DATE" "IMAGE" "TAGS[]" "COMMENTS")))

; comment section hook
(make-variable-buffer-local 'org-export-filter-final-output-functions)
(when (assoc "COMMENTS" keywords)
  (if (string-match-p "t" (cadr (assoc "COMMENTS" keywords)))
    (add-hook 'org-export-filter-final-output-functions 'insert-comment-section nil nil)))

(format
   (concat
     "<div class=\"tags-date-box\">\n"
       (generate-tags)
       "<div class=\"date\"><span class=\"timestamp\">%s</span></div>\n"
     "</div>\n")
   (cadr (assoc "DATE" keywords)))
#+END_SRC
#+NAME: generate-article-subtitle
#+BEGIN_SRC emacs-lisp :eval no :exports results :results html
(setq keywords (org-collect-keywords '("TITLE" "DESCRIPTION" "DATE" "IMAGE" "TAGS[]")))

; check if IMAGE is set
(if (string= "" (cadr (assoc "IMAGE" keywords)))
  (progn
    (setq image-fmt-string "%s")
    (setq image-path ""))
  (progn
    ; TODO hardcoded
    (setq image-path (string-replace "/index.org" "" (string-replace "/home/da/dev/andersch.dev/" "" (buffer-file-name))))
    (setq image-fmt-string "<div class=\"figure\"><img src=\"/%s/%s\" alt=\"\"></div>")))

(format (concat "<h2 class=\"subtitle\">%s</h1>" image-fmt-string)
   (cadr (assoc "DESCRIPTION" keywords)) image-path (cadr (assoc "IMAGE" keywords)))
#+END_SRC

Generate Article Snippets

#+NAME: generate-article-snippets
#+BEGIN_SRC emacs-lisp :eval no :exports results :results html :var list='()
(setq article-snippets '())
(dolist (article list)
  (with-temp-buffer
    (insert-file-contents (concat "../" (car article)))

    ;; remove everything after the snippet marker
    (setq snippet-marker "# endsnippet")
    (if (search-forward snippet-marker nil t)
      (setq begin (point))
      (error (format "Snippet marker is not set for %s" (car article))))
    (end-of-buffer)
    (setq end (point))
    (goto-char (point-min))
    (delete-region begin end)

    ;; NOTE: otherwise (buffer-file-name) in generate-article-subtitle returns nil
    (set-visited-file-name (concat "../" (car article)))

    ;; export snippet as html
    (setq org-export-show-temporary-export-buffer nil)
    (org-html-export-as-html nil nil nil t nil)
    (switch-to-buffer "*Org HTML Export*")
    (setq article-snippets (append article-snippets (list (buffer-string))))

    ;; read more link
    (setq read-more-html (format "<div class=\"read-more\"><a href=\"/%s\">READ MORE</a></div>" (string-replace "/index.org" "" (car article))))
    (setq article-snippets (append article-snippets (list read-more-html)))

    ;; dividing line between snippets
    (setq article-snippets (append article-snippets (list "<hr>\n")))))

(mapconcat #'identity article-snippets "") ; flatten string list to a string
#+END_SRC

List of articles/projects

NOTE: Unused

#+NAME: list-of-articles
#+BEGIN_SRC emacs-lisp :eval no :exports results :results raw drawer :var list='(get-article-keyword-list)
(setq list-string '())
(if (eq org-export-current-backend 'html)
  (dolist (entry list)
    (push
     (format
       (concat
        "#+BEGIN_EXPORT html\n"
        "<div class=\"image-container\">\n"
           ; NOTE "../" as a workaround
           "<a href=\"../%s\">\n"
               "<div class=\"overlay\">\n"
                   "<div class=\"title\">%s</div>\n"
                   "<div class=\"description\">%s</div>\n"
               "</div>\n"
               "<img src=\"./%s\" alt=\"\">\n"
           "</a>\n"
        "</div>\n"
        "#+END_EXPORT\n")
       (string-replace ".org" ".html" (car entry))
       (cadr (assoc "TITLE" (cadr entry)))
       (cadr (assoc "DESCRIPTION" (cadr entry)))
       (cadr (assoc "IMAGE" (cadr entry))))
    list-string)
  )
  (dolist (entry list)
    ; NOTE "../" as a workaround
    (push (format "- [[../%s][%s]]" (car entry) (cadr (assoc "TITLE" (cadr entry)))) list-string)
  ))
(mapconcat #'identity list-string "\n") ; flatten string list to a string
#+END_SRC
#+NAME: list-of-projects
#+BEGIN_SRC emacs-lisp :eval no :exports results :results raw drawer :var list=(get-project-keyword-list)
(setq list-string '())
(if (eq org-export-current-backend 'html)
  (dolist (entry list)
    (push
     (format
       (concat
        "#+BEGIN_EXPORT html\n"
        "<div class=\"image-container\">\n"
           ; NOTE "../" as a workaround
           "<a href=\"../%s\">\n"
               "<div class=\"overlay\">\n"
                   "<div class=\"title\">%s</div>\n"
                   "<div class=\"description\">%s</div>\n"
               "</div>\n"
               "<img src=\"./%s\" alt=\"\">\n"
           "</a>\n"
        "</div>\n"
        "#+END_EXPORT\n")
       (string-replace ".org" ".html" (car entry))
       (cadr (assoc "TITLE" (cadr entry)))
       (cadr (assoc "DESCRIPTION" (cadr entry)))
       (cadr (assoc "IMAGE" (cadr entry))))
    list-string)
  )
  (dolist (entry list)
    ; NOTE "../" as a workaround
    (push (format "- [[../%s][%s]]" (car entry) (cadr (assoc "TITLE" (cadr entry)))) list-string)
  ))
(mapconcat #'identity list-string "\n") ; flatten string list to a string
#+END_SRC