About AlBasmala

AlBasmala:
Blogging with Emacs & Org-mode (•̀ᴗ•́)و

Article image
  

Ξ  

Abstract

How my blog is setup (•̀ᴗ•́)و

Here are some notable features of my blog.

.org file
   │
   ▼  (C-x C-s, after-save-hook)
org-export-to-file 'blog    ← header, tags, image, badges, abstract, footer
   │
   ▼
blog--show-preview          ← xwidget side-by-side
   │
(git push)
   │
   ▼
CI: blog-publish-all        ← iterates all .org files
      ├── standalone  → org-export-to-file 'blog
      └── multiple    → blog--publish-single-subtree × N headings
                            ├── synthesize temp .org
                            ├── run export
                            └── htmlize source
                                        │
                                        ▼
                             public/ → gh-pages → alhassy.com

1. Typical workflow How do I publish an article

  1. Open an Org-mode buffer —or call blog-new-article (or blog-new-post).
  2. Invoke my/blogging-mode to get live WYSIWYG in an adjacent buffer after every save C-x C-s.
    • The mode is auto-enabled in ~/blog/ via .dir-locals.el.
  3. Until content:

    1. ✏️ Write, write, and write!
      • E.g., insert an image with C-c i i (from file) or C-c i s (screenshot)
    2. 👀 C-x C-s to save & preview

    🤔 Consider using C-x n s, or C-x n n, to focus your attention on a particular section, thereby dramatically increasing the speed at which the preview renders. 💥

  4. 🤖 git push when you're done —CI runs blog-publish-all and deploys public/ to gh-pages, index, RSS, and tag pages included.

1.1. Why not use an existing blogging platform?

I dislike coding in any website's primitive textarea, likewise for general writing.

For a brief period, I used Hashnode: I'd write in Emacs, then C-c C-e C-b h h to export current body as HTML, then paste that into Hashnode. However, Hashnode does not respect my CSS nor my inline JS. This was problematic, since I wanted to write a tiny root-meaning program to learn Arabic and to have a tiny web-app for Arabic shows for my kids.

2. Seamlessly Previewing Articles within Emacs 😲

Every save, C-x C-s, shows the final product —image, abstract, header, footer, and all styles— in a JavaScript-capable browser within Emacs; ≈WYSIWYG≈.

The pieces:

  • blog:header and blog:footer —Org links that give any Org file the blog's CSS/JS stack.
  • The blog export backend —wraps each article in header, tags, image, badges, and footer.
  • C-x C-s, under my/blogging-mode —export with blog and show the result in an xwidget to the right; for a multiple-article file, just the heading at point. Outside ~/blog/, blog-preview does the same.
  • Badges and footer —Source and History badges under the article image, since nobody scrolls to the bottom; the license and ReadRemaining.js clock at the end.

2.1. org-link/blog: Using My Blog Styles Anywhere

Details

Any Org file, blog or not, gets our styling from the Org links blog:header and blog:footer; they adapt the examples in org-deflink's docstring. Outside ~/blog/, CSS and JS load from alhassy.com/resources/ —so we need internet— and the History badge dangles.

(org-deflink blog
  "Provide the styles for 'www.alhassy.com's header and footer.

The use of 'blog:footer' aims to provide a clickable list of tags and an HTMLized version of the Org source.
For details, consult the blog--footer function.

Finally, I want to avoid any `@@backend:...@@' from appearing in the browser frame's title.
We accomplish this with the help of some handy-dandy JavaScript: Just use 'blog:sanitise-title'.
"
      (pcase o-label
        ("header" (blog--header-html (blog--info (buffer-file-name))))
        ("footer" (blog--footer (buffer-file-name)))
        ("sanitise-title" blog--sanitise-title-js)
        (_ "")))

(defconst blog--sanitise-title-js
  "<script> window.parent.document.title =  window.parent.document.title.replace(/@@.*@@/, \"\") </script>"
  "Strip any `@@backend:...@@' from the browser frame's title.")

(defun blog--header-html (post)
  "POST ↦ the site nav, CSS/JS stack, and fancy title that open every article."
  (concat
   blog-page-preamble
   blog-page-header
   "<link href=\"https://alhassy.github.io/org-notes-style.css\" rel=\"stylesheet\" type=\"text/css\" />"
   "<link href=\"https://alhassy.github.io/floating-toc.css\" rel=\"stylesheet\" type=\"text/css\" />"
   "<link href=\"https://alhassy.github.io/blog-banner.css\" rel=\"stylesheet\" type=\"text/css\" />"
   (format "<br><center><h1 class=\"post-title\">%s</h1></center>" (blog--title-html (@title post)))))

See also: How do I make a new Org link type?

2.2. The blog export backend: header, tags, image, badges, and footer

Details

Every article is exported with blog, a backend derived from html that overrides two things:

  • :options-alist —a 2-level TOC, no drawers, no Org title nor postamble, captions below tables, and clickable headlines; an article's own #+options: still win.
  • inner-template —header, tags, image, and badges; then Org's usual body; then the footer.

Its parent, blog-page, exports the index and tag pages with just the §stable anchors, so no random orgXXXXXXX id appears anywhere on the site. Nothing is global: C-c C-e h h on an unrelated Org file is untouched.

;; Every page on the site ---index and tag pages included--- gets stable, human-named anchors.
(org-export-define-derived-backend 'blog-page 'html
  :options-alist '((:html-prefer-user-labels nil nil t))
  :filters-alist '((:filter-parse-tree . blog--stable-headline-ids)
                   (:filter-final-output . blog--no-random-ids)))

;; Articles additionally get the blog look.
(org-export-define-derived-backend 'blog 'blog-page
  :options-alist '((:with-toc nil "toc" 2)
                   (:with-drawers nil "d" nil)
                   (:with-title nil "title" nil)
                   (:html-postamble nil "html-postamble" nil)
                   (:html-table-caption-above nil nil nil)
                   (:html-format-headline-function nil nil #'blog--format-headline))
  :translate-alist '((headline . blog--headline)
                     (link . blog--link)
                     (inner-template . blog--inner-template)))

(defvar blog--link-base-directory nil
  "Directory that relative file links resolve against; nil means the exported file's own directory.

`blog--publish-single-subtree' binds it to the container's directory, since it exports a temp copy.")

(defun blog--link (link desc info)
  "Org's HTML for LINK, a relative file path re-rooted at `blog-posts-directory'; the site is flat.

By way of example, in =emacs/foo.org=, =[[file:../resources/x.png]]= ↦ =resources/x.png= and
=[[file:../life/bar.org]]= ↦ =bar.html=, since every page is served from the root.  Files outside
the blog keep their links as written.  Radio links point at their =<<<target>>>='s label, not a random id."
  (let ((root (file-name-as-directory (expand-file-name blog-posts-directory)))
        (base (or blog--link-base-directory
                  (file-name-directory (or (plist-get info :input-file) default-directory))))
        (path (org-element-property :path link)))
    (when (and (equal (org-element-property :type link) "file")
               (not (file-name-absolute-p path))
               (file-in-directory-p base root))
      (let ((target (expand-file-name path base)))
        ;; Another article is a sibling on the flat site, wherever its source lives.
        (org-element-put-property link :path (if (equal (file-name-extension target) "org")
                                                 (file-name-nondirectory target)
                                               (file-relative-name target root)))))
    ;; Org's own radio links use a random id, while their <<<target>>> uses its label; agree on the label.
    (if-let ((radio (and (equal (org-element-property :type link) "radio")
                         (org-export-resolve-radio-link link info))))
        (format "<a href=\"#%s\">%s</a>" (org-html--reference radio info) desc)
      (org-html-link link desc info))))

(defun blog--inner-template (contents info)
  "(CONTENTS, INFO) ↦ header ⊕ tags ⊕ image ⊕ badges ⊕ Org's own body (TOC titled Ξ) ⊕ footer."
  (let* ((file (plist-get info :input-file))
         (post (blog--info file)))
    (concat
     (blog--header-html post)
     blog--sanitise-title-js
     "<center>" (@tags post) "</center>"
     (@image post (blog--url "resources/"))
     (blog--badges-bar post file)
     (replace-regexp-in-string "<h\\([0-9]\\)>Table of Contents</h\\1>"
                               (lambda (h) (s-replace "Table of Contents" (blog--toc-title) h))
                               (org-html-inner-template contents info) t t)
     (blog--footer file))))

(defun blog--stable-headline-ids (tree _backend info)
  "Give each headline lacking a :CUSTOM_ID: the slug `blog--ensure-useful-section-anchors' would stamp.

That function only runs on C-x C-s; here we cover files not re-saved since, so
CI never falls back to Org's random `orgXXXXXXX' anchors.  Collisions get -2, -3, …"
  (let* ((headlines (org-element-map tree 'headline #'identity info))
         (taken (delq nil (mapcar (lambda (h) (org-element-property :CUSTOM_ID h)) headlines))))
    (dolist (h headlines tree)
      (unless (org-element-property :CUSTOM_ID h)
        (let* ((base (blog--make-slug (org-element-property :raw-value h)))
               (base (if (string-empty-p base) "section" base))
               (slug base)
               (n 1))
          (while (member slug taken) (setq slug (format "%s-%d" base (cl-incf n))))
          (push slug taken)
          (org-element-put-property h :CUSTOM_ID slug))))))

(defun blog--no-random-ids (html _backend _info)
  "HTML ↦ HTML sans Org's random `orgXXXXXXX' ids; signal, with a suggested name, when a link targets one.

Headlines, `<<targets>>' and `#+name:'d elements all get stable ids, so a random
id is only ever on something nobody can link to ---unless a link does, which we
refuse to publish: its URL would change on every export."
  (let ((case-fold-search nil))
    (when (string-match "<a href=\"#org[0-9a-f]\\{7\\}\"[^>]*>\\(.*?\\)</a>" html)
      (let ((text (replace-regexp-in-string "<[^>]*>" "" (match-string 1 html))))
        (user-error "Link “%s” targets an element Org can only give a random id; name it, e.g. #+name: %s"
                    text (blog--make-slug text))))
    (replace-regexp-in-string " id=\"org[0-9a-f]\\{7\\}\"" "" html t t)))

(defvar blog--current-headline nil
  "The headline `blog--headline' is transcoding; read by `blog--format-headline'.")

(defun blog--headline (headline contents info)
  "Transcode HEADLINE as `org-html-headline' does, exposing it to `blog--format-headline'."
  (let ((blog--current-headline headline))
    (org-html-headline headline contents info)))

(defun blog--style-setup (backend)
  "Wrap the “* Abstract” section in an abstract block, for =blog= exports only.

(The abstract is an ordinary section in the source, so this is the one thing that
must happen before parsing; everything else lives in the =blog= backend.)"
  (when (org-export-derived-backend-p backend 'blog)
    (goto-char (point-min))
    ;; Previewing a narrowed subtree: links to the rest of the file are expected to dangle.
    (when (buffer-narrowed-p) (insert "#+options: broken-links:t\n"))
    (when-let ((bounds (blog--abstract-bounds)))
      (goto-char (cdr bounds))
      (insert "\n#+end_abstract\n")
      (goto-char (car bounds))
      (insert "\n#+begin_abstract\n"))))

(defun blog--abstract-bounds ()
  "Current buffer ↦ (BEG . END) of the “* Abstract” section's body, past any :PROPERTIES: drawer; nil if absent.

A top-level section ends at the next top-level heading, so plain regex suffices ---no `org-mode' needed."
  (save-excursion
    (goto-char (point-min))
    (when (re-search-forward "^\\* Abstract" nil t)
      (forward-line)
      (when (looking-at-p "^[ \t]*:PROPERTIES:")
        (re-search-forward "^[ \t]*:END:" nil t)
        (forward-line))
      (cons (point)
            (if (re-search-forward "^\\* " nil t) (match-beginning 0) (point-max))))))

(add-hook 'org-export-before-processing-hook #'blog--style-setup)

2.3. Inserting org-link/blog <em>seamlessly</em> via the export process; <em>then</em> preview with every save

Details

Previews open in an xwidget-webkit buffer, so Emacs must be built with xwidgets; on macOS:

brew reinstall emacs-plus@30 --with-xwidgets --with-imagemagick --with-dbus --with-debug

Confirm support is present with: (featurep 'xwidget-internal).

(defun blog--xwidget-buffers ()
  "Return the list of live buffers whose major mode is xwidget-webkit-mode."
  (seq-filter (lambda (b)
                (eq 'xwidget-webkit-mode (buffer-local-value 'major-mode b)))
              (buffer-list)))

(defun blog--show-preview (url)
  "Display URL in an xwidget buffer to the right of the current Org window.

Guarantees [Org source | xwidget] side-by-side layout:
- If there is already an xwidget window on the right, reuse it.
- Otherwise split the current window to the right and open there.
- The Org source window is never taken over."
  (delete-other-windows)  
  (let* ((org-win   (selected-window))
         (xw-buf    (car (blog--xwidget-buffers)))
         (xw-win    (and xw-buf (get-buffer-window xw-buf))))
    (if (and xw-win (not (eq xw-win org-win)))
        ;; Reuse the existing xwidget window ---just navigate.
        (progn
          (select-window xw-win)
          (xwidget-webkit-browse-url url)
          (select-window org-win))
      ;; No usable xwidget window ---split right and open there.
      (let ((right-win (split-window org-win nil 'right)))
        (select-window right-win)
        (xwidget-webkit-browse-url url)
        (select-window org-win)))))

(defun blog--preview-standalone ()
  "Export the current standalone Org buffer to HTML and open it in xwidget.

Called from the buffer-local after-save-hook installed by blog-preview.
Mirrors blog-preview-subtree but for files without #+article_style: multiple."
  ;; Under the blog, write to its root ---as CI does--- so a nested file's re-rooted links resolve.
  (let* ((root (expand-file-name blog-posts-directory))
         (html (if (file-in-directory-p buffer-file-name root)
                   (expand-file-name (concat (f-base buffer-file-name) ".html") root)
                 (concat (file-name-sans-extension buffer-file-name) ".html"))))
    (message "=> Previewing standalone %s..." (buffer-name))
    (org-export-to-file 'blog html)
    (when (file-exists-p html)
      (blog--show-preview (concat "file://" html)))))

(cl-defun blog-preview ()
  "Render the current Org buffer with full blog styling in an xwidget window.

Installs a buffer-local after-save-hook so that every subsequent C-x C-s
re-exports and refreshes the preview automatically.

This is the entry point for previewing *any* Org file with blog styling ---not
just files under ~/blog.  On a file outside the blog repo all CSS and JS are
loaded from absolute https://alhassy.com/resources/ URLs (internet required),
metadata keywords fall back to sensible defaults (default image, empty tags,
generic description), and the history badge links to a GitHub URL that won't
resolve.  Everything visual still works.

In ~/blog files, prefer my/blogging-mode (C-x C-s there runs the full
preprocessing pipeline: slug assignment, modified-stamp, validation, then
preview).  blog-preview is a no-op when my/blogging-mode is already active ---
that mode owns the save+preview cycle directly."
  (interactive)
  (when (bound-and-true-p my/blogging-mode)
    (message "blog-preview: my/blogging-mode is active; C-x C-s handles preview.")
    (cl-return-from blog-preview))
  ;; Kill any stale org-preview-html-mode ---it puts org-preview-html-refresh on
  ;; after-save-hook, and that errors when --html-file is nil (our path never sets it).
  (when (bound-and-true-p org-preview-html-mode)
    (org-preview-html-mode -1))
  (remove-hook 'after-save-hook #'org-preview-html-refresh t)
  ;; Ensure no xwidget buffer lingers ---otherwise Emacs might hang on re-preview.
  (let ((kill-buffer-query-functions nil))
    (mapc #'kill-buffer (blog--xwidget-buffers)))
  (if (blog--multiple-style-p)
      ;; Multiple-style: preview heading at point on every save (buffer-local hook).
      (add-hook 'after-save-hook #'blog-preview-subtree nil t)
    ;; Standalone: bypass org-preview-html-mode (file:// download bug on macOS).
    (add-hook 'after-save-hook #'blog--preview-standalone nil t)
    (blog--preview-standalone)))

(defun blog-preview-subtree ()
  "Preview the top-level heading at point as a standalone blog article.

For use in multiple-style (#+article_style: multiple) files.
Called automatically by the buffer-local after-save-hook set up by blog-preview.

Opens (or reuses) an xwidget window to the right of the Org source buffer,
maintaining a stable [Org source | xwidget] side-by-side layout."
  (interactive)
  (let ((kill-buffer-query-functions nil))
    (unless (blog--multiple-style-p)
      (user-error "Not a multiple-style file; use blog-preview instead"))
    ;; Navigate to enclosing top-level heading.
    (save-excursion
      (unless (org-at-heading-p) (outline-previous-heading))
      (while (> (org-outline-level) 1) (org-up-heading-safe))
      (let* ((tags  (mapcar #'downcase (org-get-tags)))
             (_ (when (member "noexport" tags)
                  (user-error "Heading is tagged :noexport: ---nothing to preview")))
             (_ (when (org-element-property :commentedp (org-element-at-point))
                  (user-error "Heading is COMMENT ---nothing to preview")))
             (title     (org-get-heading t t t t))
             (slug      (or (org-entry-get (point) "CUSTOM_ID")
                            (blog--make-slug (or (org-entry-get (point) "TITLE") title))))
             (all-infos (blog--info-multiple (buffer-file-name)))
             (info      (blog--find-info-by-slug slug all-infos))
             (html-out  (expand-file-name (concat slug ".html") blog-publish-directory)))
        (message "=> Previewing %s..." title)
        (blog--publish-single-subtree (point) (buffer-file-name) info)
        (when (file-exists-p html-out)
          ;; blog--show-preview reuses the existing xwidget window when present,
          ;; so the [Org | xwidget] split is stable across repeated saves.
          (blog--show-preview (concat "file://" (expand-file-name html-out))))))))

On each C-x C-s, a standalone foo.org exports beside itself as foo.html, and a container heading to public/<slug>.html. Narrowing, C-x n s, keeps this fast.

2.4. Article Footer: HTMLized Source and Git History

Details

 1: (defun blog--badges-bar (post post-file-name)
 2:   "Return the Source/History/BuyMeACoffee badge cluster for POST.
 3: 
 4: POST is the metadata alist from `blog--info'; POST-FILE-NAME is the article's
 5: filesystem path (used only when we need to htmlize a standalone article's
 6: source for the Source badge).
 7: 
 8: Surfaced directly under the article image in `blog--inner-template' so readers
 9: see the source/history/support links up top ---nobody scrolls to the bottom."
10:   (let ((source-badge
11:          (if-let (url (@htmlized_source_url post))
12:              ;; Container sub-article: source htmlized separately to <slug>.org.html.
13:              (blog--source-badge url)
14:            ;; Standalone: htmlize and return badge.
15:            (blog--htmlize-file post-file-name))))
16:     (concat
17:      "<center>"
18:      source-badge
19:      "&ensp;"
20:      (@history post)
21:      "&ensp;"
22:      "<a href=\"https://www.buymeacoffee.com/alhassy\"><img src="
23:      "\"https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee\">"
24:      "</a>"
25:      "</center>")))
26: 
27: (defun blog--footer (post-file-name)
28:   "Return the closing HTML appended to every post.
29: 
30: Just the emacs/org credit, license, Arabic-font CSS shim, and RR.js hook:
31: the Source/History/BuyMeACoffee badges have been hoisted up to sit
32: directly under the article image (see `blog--badges-bar').  For container
33: sub-articles, #+htmlized_source_url: and #+history_url: are carried on
34: the temp buffer by `blog--info'."
35:   (concat
36:    "<hr>"
37:    "<center>"
38:    (blog--css-arabic-font-setup)
39:    "<strong> Generated by Emacs and Org-mode (•̀ᴗ•́)و </strong>"
40:    (blog--license)
41:    "</center>"
42:    "<div hidden> <div id=\"postamble\" class=\"status\"> </div> </div>"
43:    (blog--read-remaining-js)))

2.5. blog–htmlize-file: Generate an htmlized version of a given source file; return an HTML badge linking to the colourised file

Details

Each .org.html page is htmlize's inline-css rendering: the file as Emacs shows it, with the tsdh-light colours loaded atop AlBasmala.el baked into the spans.

(defun blog--source-badge (url)
  "URL ↦ the “Source” badge linking to an article's colourised Org source."
  (concat "<a class=\"tooltip\" title=\"See the colourised Org source of this article; i.e., what I typed to get this nice webpage\" href=\""
          url "\"><img src=\"https://img.shields.io/badge/-Source-informational?logo=read-the-docs\"></a>"))

(defun blog--htmlize-to (name)
  "Write the current Org buffer, fully unfolded and htmlized with inline CSS, to `blog-publish-directory'/NAME.org.html.

The <title> is the buffer's file name, so callers let-bind `buffer-file-name'."
  (let ((htmlize-output-type 'inline-css)
        (inhibit-message t))
    (outline-show-all)
    (with-current-buffer (htmlize-buffer)
      (write-file (expand-file-name (concat name ".org.html") blog-publish-directory))
      (kill-buffer))))

(defun blog--htmlize-file (file-name)
  "FILE-NAME ↦ the Source badge for it, having written its colourised source to public/BASE.org.html."
  (let ((org-hide-block-startup nil))
    (with-temp-buffer
      ;; Not `find-file': that hands back the live editing buffer, and re-running
      ;; `org-mode' there resets its local state and re-fires .dir-locals.el.
      (insert-file-contents file-name)
      (org-mode)
      (let ((buffer-file-name file-name))
        (blog--htmlize-to (f-base file-name)))))
  (blog--source-badge (concat (f-base file-name) ".org.html")))

2.6. blog–license: HTML for Creative Commons Attribution-ShareAlike 3.0 Unported License

Details
(defun blog--license ()
  "Get HTML for Creative Commons Attribution-ShareAlike 3.0 Unported License."
(s-collapse-whitespace (s-replace "\n" ""
"
<center style=\"font-size: 12px\">
  <a rel=\"license\" href=\"https://creativecommons.org/licenses/by-sa/3.0/\">
     <img alt=\"Creative Commons License\" style=\"border-width:0\"
          src=\"https://i.creativecommons.org/l/by-sa/3.0/88x31.png\"/>
  </a>

  <br/>
  <span xmlns:dct=\"https://purl.org/dc/terms/\"
        href=\"https://purl.org/dc/dcmitype/Text\"
        property=\"dct:title\" rel=\"dct:type\">
     <em>Life & Computing Science</em>
  </span>

  by
  <a xmlns:cc=\"https://creativecommons.org/ns#\"
  href=\"https://alhassy.github.io/\"
  property=\"cc:attributionName\" rel=\"cc:attributionURL\">
    Musa Al-hassy
  </a>

  is licensed under a
  <a rel=\"license\" href=\"https://creativecommons.org/licenses/by-sa/3.0/\">
    Creative Commons Attribution-ShareAlike 3.0 Unported License
  </a>
</center>")))

2.7. blog–read-remaining-js: HTML to use ReadRemaining.js

Details
(defun blog--read-remaining-js ()
  "Get the HTML required to make use of ReadRemaining.js"

  ;; [Maybe Not True] ReadReamining.js does not work well with xWidget browser within Emacs,
  ;; so in-Emacs previews ---every interactive export--- go without it; CI's builds have it.
  (if (not noninteractive)
      ""

   ;; ReadRemaining.js ∷ How much time is left to finish reading this article?
   ;;
  ;; jQuery already loaded by org-special-block-extras.
  ;; "<script
  ;; src=\
  ;; "https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js\"></script>"
 "<link rel=\"stylesheet\" href=\"readremaining.js-readremainingjs/css/rr_light.css\"
     type='text/css'/>
  <script
     src=\"readremaining.js-readremainingjs/src/readremaining.jquery.js\"></script>
  <script src='readremaining.js/src/readremaining.jquery.js'
     type='text/javascript'></script>
  <script type=\"text/javascript\"> $('body').readRemaining({showGaugeDelay : 10,
     showGaugeOnStart : true}); </script>"))

ReadRemaining.js floats a clock at the bottom left —say, 4m 9s left— that adjusts dynamically as the reader scrolls down, but not up. It must sit at the end of <body>, and it reuses the jQuery that org-special-block-extras already loads; see here for the conditional.

2.8. Goal-driven development

Here's what an example article source looks like:

#+title: Example Article
#+author: Musa Al-hassy
#+email: alhassy@gmail.com
#+filetags: demo math
#+fileimage: emacs-birthday-present.png
#+description: This is an example article.

* Abstract :ignore:
Here is the extended abstract, which is rather concrete,
regarding the goals of this article.

* Body
I like maths.

* Conclusion
I like programming.

Almost all #+keyword:⋯ lines are parsed by blog--info to build the post-alist used for index/tag-page generation.

2.8.1. The Two Article Styles: standalone vs multiple

We support two ways to author blog posts, selectable per-file via #+article_style:.

  1. standalone (the default)

    One .org file = one article; its file-level keywords (#+title:, #+filetags:, …) supply the metadata.

  2. multiple (container files)

    One .org file = N articles, one per top-level heading. Starting a post is then just C-RET, a few prompts, and writing —no new file, slug, or keywords.

    A container file looks like this:

    #+title: My Life Posts
    #+article_style: multiple
    
    * A Walk in the Park                       :life:
    :PROPERTIES:
    :DATE:        <2026-05-03>
    :DESCRIPTION: An evening walk with the kids.
    :IMAGE:       park.png 350 350
    :END:
    
    ** Abstract :ignore:
    An evening walk with the kids ---the light was perfect.
    
    ** The Walk
    ...
    
    * Quick Emacs Tip: visual-line-mode        :emacs:draft:
    :PROPERTIES:
    :DATE:        <2026-05-04>
    :DESCRIPTION: One line, no scroll bar.
    :CUSTOM_ID:   quick-emacs-tip-visual-line-mode
    :END:
    ...
    
    * Private scratch                          :noexport:
    ...
    

    Key rules:

    • Heading tags → article tags, sans structural ones (noexport, draft, ignore, details, …).
    • :draft: → a draft; blog-new-post adds it, and we axe it when ready.
    • :noexport: or COMMENT → skipped entirely; never exported or indexed.
    • :TITLE: → the display title and slug source, overriding a decorated heading.
    • :CUSTOM_ID: → the URL slug; see §slugs.
    • Abstract → :ABSTRACT:, else the ** Abstract :ignore: child, else the first paragraph.
  3. How the export pipeline connects the two styles

    The blog backend reads an article's metadata from file-level #+keyword: lines, via blog--info; a container heading keeps its metadata in a :PROPERTIES: drawer instead. So blog--publish-single-subtree turns the heading into a synthetic temp file:

    1. blog--info-subtree reads the heading's metadata.
    2. The temp file gets the matching file-level keywords ---#+title:, #+filetags:, #+fileimage:, #+description:— plus #+history_url: and #+htmlized_source_url:, pointing the badges at the container's git log and the per-article colourised source.
    3. The subtree body is pasted in and promoted a level; e.g., ** Abstract :ignore: ↦ * Abstract :ignore:, just where blog--style-setup looks for it.
    4. We export with the blog backend, as for any standalone post.
    5. A narrowed copy of the subtree is htmlized into <slug>.org.html.

    Both styles thus share one export pipeline; they differ only in how they reach it.

  4. Slug derivation, write-back, and global uniqueness

    By way of example, the heading * Quick Emacs Tip: visual-line-mode is published at alhassy.com/quick-emacs-tip-visual-line-mode.

    • A container heading's slug is its :CUSTOM_ID:; C-x C-s stamps a missing one with blog--make-slug of the title, so later title edits never shift the URL.
    • blog--validate-slugs refuses a slug that isn't lower-case kebab-case, or that another article —container or standalone— already owns, naming both.

    Each heading is then just another post-alist, so blog-make-index-page needs no special case.

    Org exports #+description as HTML metadata, so a link pasted into social media unfurls into a card with its image, description, and author. We keep it a terse one-liner; the longer story goes in the Abstract heading.

    Below we make new articles and read their metadata into post-alists.

2.8.2. Basic facts (global variables) of my blog

Details

The .org sources are first-class citizens; the generated .html is not. CI exports into public/ —our blog-publish-directory, gitignored on master— and deploys it to gh-pages; see §Deployment via CI.

Subdirectories are ours alone: we may file articles under emacs/ or life/ to stay sane, but the blog never sees them. blog--org-files finds every .org file recursively, every page is served flat from the root —so basenames must be unique, as blog--validate-slugs checks— and blog--link re-roots relative links; e.g., ../resources/foo.png in emacs/foo.org ↦ resources/foo.png.

(defvar blog-title "Life & Computing Science"
  "Title of the blog.")

(defvar blog-url "https://alhassy.com"
  "URL of the blog.")

(defun blog--url (&rest path)
  "PATH… ↦ its absolute URL on the blog; e.g., (blog--url \"tag-\" \"emacs\" \".html\")."
  (apply #'concat blog-url "/" path))

(defun blog--history-url (file)
  "FILE ↦ the GitHub commit history of FILE, by its path relative to `blog-posts-directory'."
  (format "https://github.com/alhassy/alhassy.github.io/commits/master/%s"
          (file-relative-name (expand-file-name file) (expand-file-name blog-posts-directory))))

(defvar blog-publish-directory "~/blog/public/"
  "Directory containing published HTML files.

HTML is a build artefact; the .org sources in ~/blog/ are first-class.
On master we keep only sources; CI exports everything to public/ and deploys
public/'s contents to the gh-pages branch that GitHub Pages serves.
That gives flat URLs (alhassy.com/foo) while the master working tree stays clean.")

(defvar blog-posts-directory "~/blog"
  "Directory containing source Org files.

All article .org files ---including AlBasmala.org itself--- live here, in any
subdirectories we like; see `blog--org-files'.  blog--compute-posts-and-pages collects every .org file with #+date:
as a post and every file with #+site_nav: as a nav page; files with neither
(e.g. MathJaxPreamble.org) are silently skipped.
See blog-make-index-page and blog-publish-directory.")

(defun blog--org-files ()
  "Every .org file under `blog-posts-directory', recursively; subdirectories are for us, not the blog.

Skips hidden directories, `blog-publish-directory', and resources/ (vendored :REDIRECT: sources)."
  (let ((root (expand-file-name blog-posts-directory))
        (skip (list (directory-file-name (expand-file-name blog-publish-directory))
                    (expand-file-name "resources" blog-posts-directory))))
    (directory-files-recursively
     root "\\`[^.#].*\\.org\\'" nil
     (lambda (dir) (not (or (member dir skip) (string-prefix-p "." (f-filename dir))))))))

2.8.3. blog-new-article: Helper function to make a new article

Details
 1: (defun blog-new-article ()
 2: "Make a new article for my blog; prompting for the necessary ingredients.
 3: 
 4: If the filename entered already exists, we simply write to it.
 5: The user notices this and picks a new name.
 6: 
 7: This sets up a new article based on existing tags and posts.
 8: + Use C-SPC to select multiple tag items
 9: 
10: Under `my/blogging-mode', every save, C-x C-s, live-previews the article."
11:   (interactive)
12:   (let (file desc)
13: 
14:     ;; Subdirectories are welcome, e.g., "emacs/new-trick.org"; the blog stays flat regardless.
15:     (setq file (expand-file-name
16:                 (completing-read "Filename (Above are existing): "
17:                                  (mapcar (lambda (f) (file-relative-name f (expand-file-name blog-posts-directory)))
18:                                          (blog--org-files)))
19:                 blog-posts-directory))
20:     (make-directory (file-name-directory file) t)
21: 
22:     ;; For some reason, 'find-file' in the thread above
23:     ;; wont let the completing-read display the possible completions.
24:     (find-file file)
25: 
26:     (insert "#+title: " (read-string "Title: ")
27:             "\n#+author: " user-full-name
28:             "\n#+email: "  user-mail-address
29:             "\n#+modified: " (format-time-string "%Y-%m-%d")
30:             "\n#+filetags: " (s-join " " (completing-read-multiple "Tags: " blog-tags))
31:             "\n#+fileimage: emacs-birthday-present.png"
32:             ;; "\n#+fileimage: " (completing-read
33:             ;;                    "Image: "
34:             ;;                    (mapcar #'f-filename (f-entries "~/blog/resources/")))
35:             ;; "\n#+include: ../MathJaxPreamble.org" ;; TODO. Is this someting I actually want here? If so, then consider tangling it from AlBasmala! (and add the whitespace-MathJax setup from above!)
36:             "\n#+description: "
37:                (setq desc (read-string "Article Purpose: "))
38:             "\n\n* Abstract :ignore: \n" desc
39:             "\n\n* ???")
40:     (save-buffer)
41:     (blog-preview)))

2.8.4. blog-new-post: Insert a heading skeleton in a multiple-style container file

Details

In a container file, C-RET (or M-x blog-new-post) asks for a title, a description, and optional tags; then we write.

(defun blog-new-post ()
  "Insert a new article skeleton at point in a multiple-style container file.

Prompts for title, description, and (optionally) tags.  The image is
selected automatically from blog-tag-image-alist based on the tags entered
-- no image prompt required.  It can be overridden afterwards by editing the
:IMAGE: property in the drawer.

The :draft: heading tag is added automatically so the article is treated as
a draft until you remove it before publishing."
  (interactive)
  (unless (blog--multiple-style-p)
    (user-error "Not a multiple-style file; use blog-new-article for standalone posts"))
  (let* ((title       (read-string "Article title: "))
         (description (read-string "One-line description: "))
         (tags-input  (read-string "Tags (space-separated, optional): "))
         (tags        (s-split " " tags-input t))
         (image       (blog--image-for-tags tags))
         (tag-suffix  (if tags (concat ":" (s-join ":" tags) ":") ""))
         (today       (format-time-string "%Y-%m-%d")))
    (unless (bolp) (newline))
    (insert
     "* " title " :draft:" tag-suffix "\n"
     ":PROPERTIES:\n"
     ":MODIFIED:    " today "\n"
     ":DESCRIPTION: " description "\n"
     ":IMAGE:       " image "\n"
     ":END:\n"
     "\n"
     "** Abstract :ignore:\n"
     description "\n"
     "\n"
     "** ???\n")
    ;; Enable preview for this container if not already active.
    (unless (member #'blog-preview-subtree after-save-hook)
      (blog-preview))))

2.8.5. Convenient accessor methods: Given a JSON hashmap, get the specified key values

Details

Since "accessor" begins with 'a', and '@' looks like one, every accessor starts with '@'; @history, @tags, and @image also render their value as HTML, for the index page.

;; Convenient accessor methods: Given a JSON hashmap, get the specified key values.
;; Later, we redefine these, for example `@image' will actually produces the HTML for the image.
;; Example usage: (@title (seq-elt posts 0))  ⇒  "Java CheatSheet"

;; Extract the '#+title:' from POST-FILENAME.
(defun @title       (json) (map-elt json "title"))

(defun blog--title-html (title)
  "Strip Org export-snippet markers from TITLE so embedded HTML renders.

Titles like 'AlBasmala: @@html: <br>@@ …' carry Org export snippets that
`#+begin_export html' blocks and raw format strings ship verbatim ---so the
`@@html:' / `@@' markers leak into the output.  Axe them, keeping the payload
(here, the literal `<br>') so HTML contexts render it as intended.  RSS feeds
should keep calling (esc (@title post)) and avoid this helper: `<br>' would
then escape to '&lt;br&gt;' and be shown as text."
  (thread-last title
               (string-replace "@@html:" "")
               (string-replace "@@" "")))

;; TODO: Consider using: (format-time-string "%d %b %Y" ⋯) to have the same format across all articles.
(defun @date (json)
  "Extract the #+modified: field from JSON."
  (map-elt json "date"))

(defun @file        (json) (map-elt json "file"))
(defun @slug        (json) (map-elt json "slug"))
(defun @description (json) (map-elt json "description"))
(defun @abstract    (json) (map-elt json "abstract"))

;; Returns absolute URL to the published POST-FILENAME.
;;
;; This function concatenates publish URL and generated custom filepath to the
;; published HTML version of the post.
;;
(defun @url                  (json) (map-elt json "url"))

;; For container sub-articles, the synthetic #+htmlized_source_url: keyword
;; carries the URL of the per-article colourised source view.
;; Returns nil for ordinary standalone articles (blog--footer falls back to blog--htmlize-file).
(defun @htmlized_source_url  (json) (map-elt json "htmlized_source_url"))

2.8.6. @history: Get an HTML badge that points to the Github history of a given file name, in my blog

Details
(defun @history (json)
  "Get an HTML badge that points to the Github history of a given file name, in my blog."
  (concat
   "<a class=\"tooltip\" title=\"See the various edits to this article over time\" href=\""
   (map-elt json "history")
   "\"><img src=\"https://img.shields.io/badge/-History-informational?logo=github\"></a>"))

2.8.7. @tags: Get an HTML listing of tags, as shields.io bages, associated with the given file

Details

Org forbids dashes in tags, so we write :programming_language: and readers see programming-language, on the badge and in tag-programming-language.html; blog--tag-slug does the conversion.

(defun blog--tag-slug (tag)
  "Convert an internal TAG name (underscores, Org-compatible) to a kebab-case slug.

Org forbids dashes in tag names ---we use underscores internally and replace them
with dashes for display and URLs so the reader sees kebab-case everywhere:
  programming_language  →  programming-language
  tag-programming_language.html  →  tag-programming-language.html"
  (s-replace "_" "-" (downcase tag)))

(defun @tag-list (json)
  "JSON ↦ its space-separated \"tags\" as a list, sans empty strings."
  (s-split " " (map-elt json "tags") t))

(defun @tags (json)
  "Get an HTML listing of tags, as shields.io badges, associated with the given file.

Tag names are stored with underscores (Org syntax requirement) but rendered with
dashes everywhere ---display label, URL slug--- so readers see kebab-case.

Example use:  (@tags (seq-elt blog-posts 0))
"
  (concat
  ;; Badges implementation
   (concat
    (format "<a href=\"https://alhassy.github.io/tags.html\"> %s </a>"
            (org-link/octoicon "tag" nil 'html))
    (s-join " "
            (--map  (let ((slug (blog--tag-slug it)))
                      (org-link/badge
                       (format "|%s|grey|%stag-%s.html"
                               slug (blog--url) slug)
                       nil 'html))
                    (@tag-list json))))))

2.8.8. @image

Details

Every article has an image ^_^ —a resources/ filename, a path, or a URL; emacs-birthday-present.png otherwise.

(cl-defun @image (json &optional explicit-image-path-prefix)
  "Assemble the value of '#+fileimage: image width height border?' as an HTML form.

By default, the image should be located in the top-level resources/ directory.
If the image is located elsewhere, or is a URL, is dictated by the presence of a `/'
in the image path.

Example use:  (@image (seq-elt blog-posts 0))

Here are 4 example uses:

#+fileimage: emacs-birthday-present.png
#+fileimage: ../resources/emacs-birthday-present.png
#+fileimage: https://upload.wikimedia.org/wikipedia/en/6/64/Dora_and_Boots.jpg 350 300
#+fileimage: https://unsplash.com/photos/Vc2dD4l57og

+ Notice that the second indicates explicit width and height.
+ (To make the first approach work in previews regardless of where the
   buffer's file lives, we need the variable EXPLICIT-IMAGE-PATH-PREFIX,
   which `blog--inner-template' sets to the deployed
   https://alhassy.com/resources/ URL rather than a local relative path.
   This requires a slash at the end.)
+ The unsplash approach is specific: It shows the *main* image in the provided URL, and links to the provided URL.
"
  (-let [(image width height no-border?) (s-split " " (map-elt json "image"))]
    (setq width (or width 350))
    (setq height (or height 350))
    (setq no-border? (if no-border? "" "style=\"border: 2px solid black;\""))
    (cond
     ((s-contains? "/" image) t) ;; It's a URL, or explicit path, do nothing to it.
     (explicit-image-path-prefix (setq image (format "%s%s"  explicit-image-path-prefix image)))
     ((not (s-contains? "/" image)) (setq image (format "resources/%s" image))))
    (-let [unsplash (cl-second (s-match ".*unsplash.com/photos/\\(.*\\)" image))]
      (setq href (if unsplash (concat "https://unsplash.com/photos/" unsplash) image))
      (setq title (format "Image credit %s" (if unsplash (concat "https://unsplash.com/photos/" unsplash) image)))
      (setq src (if unsplash (format "https://source.unsplash.com/%s/%sx%s" unsplash width height) image))
      (s-collapse-whitespace
       (format "<center class=\"post-image\"><a href=\"%s\" class=\"tooltip\" title=\"%s\"><img src=\"%s\" alt=\"Article image\"
             %s width=\"%s\" height=\"%s\" align=\"top\"/></a></center>"
              href title src no-border? width height)))))

2.8.9. blog–info: Core helper to get the plist/JSON metadata about each post

Details
(defun blog--info (post-filename)
  "Extract the `#+BLOG_KEYWORD: VALUE` pairs from POST-FILENAME.

Example use: (blog--info \"~/blog/HeytingAlgebra.org\")

For container sub-articles, the temp file may carry synthetic keywords:
  #+history_url:         ---overrides the auto-computed GitHub history URL
  #+htmlized_source_url: ---URL for the per-article colourised source badge
These are ignored for ordinary standalone files (regex yields nil, fallback applies)."
  (let ((case-fold-search t))
    (with-temp-buffer
      (insert-file-contents post-filename)
      (let* ((keyword-pairs
              (cl-loop for (prop.name prop.regex prop.default) on
                    `("title"                "^\\#\\+title:[ ]*\\(.+\\)$"                ,post-filename
                      "date"                 "^\\#\\+modified:[ ]*\\(.+\\)$"             ,(format-time-string "%Y-%m-%d")
                      "image"                "^\\#\\+fileimage: \\(.*\\)"                "emacs-birthday-present.png 350 350"
                      "description"          "^\\#\\+description:[ ]*\\(.+\\)$"          "I learned something neat, and wanted to share!"
                      "tags"                 "^\\#\\+filetags:[ ]*\\(.+\\)$"             "" ;; String; Space-separated
                      "history_url"          "^\\#\\+history_url:[ ]*\\(.+\\)$"          nil
                      "htmlized_source_url"  "^\\#\\+htmlized_source_url:[ ]*\\(.+\\)$"  nil
                      "site_nav"             "^\\#\\+site_nav:[ ]*\\(.+\\)$"             nil
                      "modified"             "^\\#\\+modified:[ ]*\\(.+\\)$"             nil
                      )
                  by 'cdddr
                  ;; See: https://stackoverflow.com/questions/19774603/convert-alist-to-from-regular-list-in-elisp
                  do (goto-char (point-min))
                  collect (cons prop.name
                                (if (search-forward-regexp prop.regex nil t)
                                    (match-string 1)
                                  prop.default)))))
        (-snoc
         (cons
          (cons "file" (f-base post-filename))
          keyword-pairs)
         (cons "url" (blog--url (f-base post-filename)))
         ;; Prefer an explicit #+history_url: (injected for container sub-articles)
         ;; over the auto-derived URL based on the file basename.
         (cons "history"
               (or (cdr (assoc "history_url" keyword-pairs))
                   (blog--history-url post-filename)))
         (cons "abstract" (when-let ((bounds (blog--abstract-bounds)))
                            (buffer-substring-no-properties (car bounds) (cdr bounds)))))))))

2.8.10. blog–article-style, blog–make-slug: Utilities for the multiple article style

Details

blog--article-style reads #+article_style:; blog-preview, blog-publish-all, and blog-new-post dispatch on it. A slug names both <slug>.html and alhassy.com/<slug>; see §slugs.

  1. Tag-based image selection: blog--image-for-tags

    A heading without an :IMAGE: gets one from its tags: blog--image-for-tags takes the first match in blog-tag-image-alist, else emacs-birthday-present.png. blog-new-post pre-fills its :IMAGE: line the same way. Entries are (tag . "image-spec"), the spec being anything #+fileimage: accepts; e.g., filename, a URL, or filename w h.

    (defvar blog-tag-image-alist
      '(("emacs"    . "./resources/emacs-birthday-present.png 350 350")
        ("lisp"     . "./resources/emacs-birthday-present.png 350 350")
        ("org"      . "./resources/org_logo.png 350 350")
        ("haskell"  . "./resources/haskell-logo.png 350 350")
        ("java"     . "./resources/modern-java.png 350 350")
        ("arabic"   . "./resources/arabic-irab.png 350 350")
        ("life"     . "./resources/musa_pink.jpg 350 350")
        ("family"   . "./resources/family-tree.png 350 350")
        ("karate"   . "./resources/fukyu-kata.png 350 350"))
      "Alist mapping Org heading tags to default image specs for blog posts.
    First match wins.  The image spec is anything #+fileimage: accepts.
    Used by blog--image-for-tags to avoid requiring an explicit :IMAGE: property
    on every container sub-article.")
    
    (defun blog--image-for-tags (tags)
      "Return a default image spec for the given list of TAGS (strings, lowercased).
    Checks blog-tag-image-alist in order; returns the first match.
    Falls back to the global default image when no tag matches."
      (or (cdr (seq-find (lambda (pair) (member (car pair) tags))
                         blog-tag-image-alist))
          "./resources/emacs-birthday-present.png 350 350"))
    
    (defun blog--file-keyword (file-or-nil key &optional default)
      "Return the value of `#+KEY:' as a trimmed string.
    
    If FILE-OR-NIL is nil, scan the current buffer; otherwise read FILE-OR-NIL
    into a temp buffer first.  Returns DEFAULT when the keyword is absent.
    Case-insensitive on the key.  KEY omits the leading `#+' and trailing `:'."
      (let ((case-fold-search t)
            (rx (concat "^#\\+" (regexp-quote key) ":[ ]*\\(.*\\)$")))
        (cl-flet ((scan ()
                    (save-excursion
                      (goto-char (point-min))
                      (if (re-search-forward rx nil t) (s-trim (match-string 1)) default))))
          (if (null file-or-nil)
              (scan)
            (with-temp-buffer
              (insert-file-contents file-or-nil)
              (scan))))))
    
    (defun blog--article-style (&optional filename)
      "Return the #+article_style keyword for FILENAME (default: current buffer file).
    Returns \"multiple\" or \"standalone\" (the default when the keyword is absent)."
      (let ((file (or filename (buffer-file-name))))
        (if (not file)
            "standalone"
          (blog--file-keyword file "article_style" "standalone"))))
    
    (defun blog--make-slug (title)
      "Convert TITLE to a URL-safe kebab-case slug.
    
    Lowercases, strips non-alphanumeric characters (keeping spaces and existing
    dashes), then collapses runs of spaces/dashes into a single dash."
      (thread-last title
        downcase
        (replace-regexp-in-string "[^[:alnum:][:space:]-]" "")
        (replace-regexp-in-string "[[:space:]]+" "-")
        (replace-regexp-in-string "-+" "-")
        (replace-regexp-in-string "^-\\|-$" "")))
    

2.8.11. blog–info-subtree, blog–info-multiple: Extract metadata from container headings

Details

The multiple-style analogues of blog--info: blog--info-subtree reads a heading's drawer and tags via org-element. blog--info-multiple parses the container once —per-heading parses would be quadratic— and yields one alist per publishable heading, with blog--info's keys plus "slug" and "container".

(defun blog--info-subtree-abstract (headline-node)
  "Return the abstract text for HEADLINE-NODE, or nil.

Priority:
1. :ABSTRACT: property on the headline.
2. A child heading whose title matches /abstract/i (e.g. ** Abstract :ignore:).
3. The first paragraph element in the heading body."
  (or
   ;; 1. Explicit property
   (org-element-property :ABSTRACT headline-node)
   ;; 2. Child heading named Abstract
   (org-element-map headline-node 'headline
     (lambda (child)
       (when (and (= (org-element-property :level child)
                    (1+ (org-element-property :level headline-node)))
                  (string-match-p "abstract"
                                  (downcase (or (org-element-property :raw-value child) ""))))
         (org-element-interpret-data (org-element-contents child))))
     nil t)
   ;; 3. First paragraph fallback
   (org-element-map headline-node 'paragraph
     (lambda (para) (org-element-interpret-data para))
     nil t)))

(defun blog--info-subtree (headline-node container-file slug)
  "Extract post metadata for HEADLINE-NODE from CONTAINER-FILE with SLUG.

Returns nil when the headline carries a :noexport: tag or the COMMENT keyword.
Returns an alist with the same keys as blog--info plus \"slug\" and \"container\"."
  (let* ((heading-tags (mapcar #'downcase
                               (org-element-property :tags headline-node))))
    (unless (or (member "noexport" heading-tags)
                (org-element-property :commentedp headline-node))
      (let* (;; draft?
             (draft? (or (member "draft" heading-tags)
                         (equal "t" (org-element-property :DRAFT headline-node))))
             ;; title: :TITLE: property overrides heading text
             (title (or (org-element-property :TITLE headline-node)
                        (org-element-property :raw-value headline-node)))
             ;; date ---prefer :MODIFIED:, fall back to :DATE: for old articles
             (date-raw (or (org-element-property :MODIFIED headline-node)
                           (org-element-property :DATE headline-node)))
             (date (if date-raw
                       (replace-regexp-in-string "[<>]" "" date-raw)
                     (format-time-string "%Y-%m-%d")))
             ;; description
             (description (or (org-element-property :DESCRIPTION headline-node)
                              "I learned something neat, and wanted to share!"))
             ;; image: explicit :IMAGE: property wins; otherwise derive from tags
             (image (or (org-element-property :IMAGE headline-node)
                        (blog--image-for-tags heading-tags)))
             ;; tags: merge :TAGS: property with heading tags; strip structural tags
             (structural-tags '("noexport" "draft" "ignore" "details" "details_orange"
                                "details_green" "header" "reexport" "noreexport"))
             (prop-tags  (s-split " " (or (org-element-property :TAGS headline-node) "") t))
             (org-tags   (seq-remove (lambda (tag) (member tag structural-tags)) heading-tags))
             (all-tags   (seq-uniq (append prop-tags org-tags)))
             (tags-str   (s-join " " all-tags))
             ;; abstract
             (abstract (blog--info-subtree-abstract headline-node))
             ;; urls
             (container-base (f-base container-file))
             (url     (blog--url slug))
             (history (blog--history-url container-file)))
        (list (cons "file"        container-base)
              (cons "slug"        slug)
              (cons "container"   container-base)
              (cons "title"       title)
              (cons "date"        date)
              (cons "image"       image)
              (cons "description" description)
              (cons "tags"        tags-str)
              (cons "url"         url)
              (cons "history"     history)
              (cons "abstract"    abstract)
              (cons "draft"       (if draft? "t" nil))
              (cons "redirect"    (org-element-property :REDIRECT headline-node))
              (cons "modified"    (org-element-property :MODIFIED headline-node))
              (cons "site_nav"    (org-element-property :SITE_NAV headline-node)))))))

(defun blog--info-multiple (container-file)
  "Return a list of post-alists for all publishable top-level headings in CONTAINER-FILE.

CONTAINER-FILE must carry #+article_style: multiple.
Headings tagged :noexport: or carrying the COMMENT keyword are excluded.
Headings tagged :draft: are included but marked."
  (with-temp-buffer
    (insert-file-contents container-file)
    (org-mode)
    (let* ((tree (org-element-parse-buffer))
           ;; Collect all level-1 headings
           (top-headings
            (org-element-map tree 'headline
              (lambda (h)
                (when (= (org-element-property :level h) 1) h))))
           ;; A slug is the heading's :CUSTOM_ID:.  Until C-x C-s stamps one, fall back to
           ;; what it will stamp; `blog--validate-slugs' catches any collision.
           (slugs
            (mapcar (lambda (h)
                      (or (org-element-property :CUSTOM_ID h)
                          (blog--make-slug (or (org-element-property :TITLE h)
                                               (org-element-property :raw-value h)))))
                    top-headings)))
      ;; Build alists, skipping noexport/COMMENT headings (blog--info-subtree returns nil for them)
      (delq nil
            (cl-mapcar (lambda (h slug) (blog--info-subtree h container-file slug))
                       top-headings slugs)))))

2.8.12. REDIRECT: zero-duplication bridges to external Org files

Details

Often a project's lovingly maintained README.org, or ~/.emacs.d/init.org, is the article; copying it into a container heading is pointless. Instead, give the heading a :REDIRECT: of a local path or an https:// URL: its body becomes a single #+include: of that file, and Org does the rest.

* My Literate Emacs Config :emacs:lisp:
:PROPERTIES:
:DATE:        <2024-01-15>
:DESCRIPTION: A literate Emacs configuration, tangled from Org and readable as prose.
:REDIRECT:    ~/.emacs.d/init.org
:END:

* Remote README :docs:
:PROPERTIES:
:DATE:        <2024-02-01>
:REDIRECT:    https://raw.githubusercontent.com/alhassy/example/master/README.org
:END:

The first heading is published with the usual synthetic keywords (#+title:, #+date:, …) followed by

#+include: "/…/blog/resources/redirects/my-literate-emacs-config.org"

blog--info-subtree carries the "redirect" key along, so the index, tag pages, and RSS treat these articles like any other.

  1. Vendoring redirects so CI can resolve them
    Details

    A :REDIRECT: of ~/.emacs.d/init.org works on our laptop and fails on CI, which has no ~/.emacs.d/. So we vendor each source —copied or fetched— into the git-tracked resources/redirects/<slug>.org:

    • On preview, blog--publish-single-subtree vendors into its temp file, leaving the source's :REDIRECT: alone.
    • On blog-publish-all, blog--vendor-redirects vendors first and rewrites the property to the in-repo path; e.g., :REDIRECT: resources/redirects/my-literate-emacs-config.org.
    (defvar blog-redirects-subdir "resources/redirects/"
      "Subdirectory of `blog-posts-directory' where :REDIRECT: sources are
    vendored for CI.  Relative path; must end with a trailing slash.")
    
    (defun blog--url-p (path)
      "Return non-nil if PATH is an HTTP(S) URL."
      (string-match-p "\\`https?://" path))
    
    (defun blog--already-vendored-p (path)
      "Return non-nil if PATH is already rooted at `blog-redirects-subdir'."
      (string-prefix-p blog-redirects-subdir path))
    
    (defun blog--vendor-one-redirect (slug redirect)
      "Fetch REDIRECT (a local path or http(s) URL) into
    `blog-redirects-subdir'/SLUG.org and return the vendored relative path.
    A no-op if REDIRECT already points into `blog-redirects-subdir'."
      (if (blog--already-vendored-p redirect)
          redirect
        (let* ((dest-dir (expand-file-name blog-redirects-subdir blog-posts-directory))
               (dest     (expand-file-name (concat slug ".org") dest-dir)))
          (make-directory dest-dir t)
          (cond
           ((blog--url-p redirect)
            (url-copy-file redirect dest t))
           (t
            (let ((src (expand-file-name redirect)))
              (unless (file-exists-p src)
                (user-error "REDIRECT source does not exist: %s" src))
              (copy-file src dest t))))
          (call-process "git" nil nil nil "add" dest)
          (concat blog-redirects-subdir slug ".org"))))
    
    (defun blog--map-publishable-top-headings (fn)
      "Walk top-level headings in the current buffer and call FN with point on each.
    
    Only runs on multiple-style containers.  Skips COMMENTed headings ---the
    per-heading FN is responsible for any further filtering (e.g. :noexport:)."
      (when (blog--multiple-style-p)
        (save-excursion
          (goto-char (point-min))
          (while (re-search-forward "^\\* " nil t)
            (unless (org-element-property :commentedp (org-element-at-point))
              (funcall fn))
            (org-end-of-subtree t t)))))
    
    (defun blog--assign-slugs ()
      "Walk the current buffer's top-level headings; for each without a :CUSTOM_ID:
    property, derive one from the heading title (or :TITLE: override) and
    write it back via `org-entry-put'.
    
    Called from the C-x C-s preview binding so that, by the time a source
    lands in git, every container heading carries a stable :CUSTOM_ID:.  CI then
    reads :CUSTOM_ID: and errors out if missing, rather than silently generating
    one and writing back to a throwaway checkout."
      (blog--map-publishable-top-headings
       (lambda ()
         (let ((tags (mapcar #'downcase (org-get-tags))))
           (unless (or (member "noexport" tags)
                       (org-entry-get (point) "CUSTOM_ID"))
             (let* ((title (org-get-heading t t t t))
                    (slug  (blog--make-slug
                            (or (org-entry-get (point) "TITLE") title))))
               (org-entry-put (point) "CUSTOM_ID" slug)
               (message "=> Assigned :CUSTOM_ID: %s to %s" slug title)))))))
    
    (defun blog--vendor-redirects ()
      "Walk the current buffer's top-level headings; for each with a :REDIRECT:
    property, vendor the source into `blog-redirects-subdir' and rewrite the
    property to the vendored in-repo path.  Only touches multiple-style containers.
    
    Called by `blog-publish-all' (CI) so the rewritten path lands in git and CI
    can resolve it on a clean checkout.  NOT called on preview/C-x C-s ---preview
    vendors on-the-fly inside `blog--publish-single-subtree' without touching the
    source buffer.
    
    Relies on `blog--assign-slugs' having run first so every heading carries
    a :CUSTOM_ID: property we can build the vendored filename from."
      (blog--map-publishable-top-headings
       (lambda ()
         (let ((redirect (org-entry-get (point) "REDIRECT"))
               (slug     (org-entry-get (point) "CUSTOM_ID")))
           (when (and redirect slug (not (blog--already-vendored-p redirect)))
             (let ((vendored (blog--vendor-one-redirect slug redirect)))
               (org-entry-put (point) "REDIRECT" vendored)
               (message "=> Vendored redirect for %s ← %s" slug redirect)))))))
    
Details

A container heading with a :SITE_NAV: property is a site page, not a post: it joins blog-pages as well as blog-posts, and is linked from the header of every page. For instance,

* AlBasmala :emacs:lisp:
:PROPERTIES:
:DATE:        <2020-01-01>
:DESCRIPTION: How this blog works.
:CUSTOM_ID:   AlBasmala
:SITE_NAV:    AlBasmala
:END:

(blog--refresh-posts) rebuilds the header via blog--rebuild-preamble, its links in the order the headings appear in their containers.

2.8.14. The <code>#+beginabstract</code> is an Org-mode Special Block

orange

An article's Abstract section previews it on the index page; see §2.8 for a template. The block below adapts org-defblock's docstring examples.

(org-defblock abstract (main) nil
  "Render a block in a slightly narrowed blueish box, titled \"Abstract\".

   Supported backends: HTML. "
   (format (concat
            "<div class=\"abstract\" style=\"border: 1px solid black;"
            "padding: 1%%; margin-top: 1%%; margin-bottom: 1%%;"
            "margin-right: 10%%; margin-left: 10%%; background-color: lightblue;\">"
            "<center> <strong class=\"tooltip\""
            "title=\"What's the goal of this article?\"> Abstract </strong> </center>"
            "%s </div>")
           contents))

★     ★     ★

For example, the source:

#+begin_abstract
In this article, we learn to have fun!
#+end_abstract

Results in:

Abstract

In this article, we learn to have fun!

2.8.15. Generating the Index Page

Details

blog-make-index-page renders index.html: one card per article, with its title, date, image, abstract, and a read-more badge.

  1: (defun blog--greeting (&optional tag)
  2:   "Return the index/tag-page greeting string, optionally specialised to TAG.
  3: 
  4: The `thread-first` / `doc:cl-loop' name-drop makes sense on the landing page
  5: (Elisp is the site's /lingua franca/) but reads as a non-sequitur on a
  6: tag-scoped page like `life' ---so tag pages get a trimmed variant."
  7:   (if tag
  8:       (format "Here are some of my latest thoughts on %s... badge:Made_with|Lisp|success|https://alhassy.github.io/ElispCheatSheet/CheatSheet.pdf|Gnu-Emacs tweet:https://alhassy.com @@html:<br><br>@@"
  9:               (blog--tag-slug tag))
 10:     "Here are some of my latest thoughts... badge:Made_with|Lisp|success|https://alhassy.github.io/ElispCheatSheet/CheatSheet.pdf|Gnu-Emacs such as doc:thread-first and doc:cl-loop (•̀ᴗ•́)و tweet:https://alhassy.com @@html:<br><br>@@"))
 11: 
 12: (defun blog--card (post)
 13:   "Return the Org source for one article card.
 14: 
 15: No Org heading is emitted ---Org would render it as an <h2> *in addition*
 16: to our own <h2 class=\"title\"> inside the export block, giving every
 17: card a duplicated title.  Per-tag filtering is done in Elisp before
 18: cards are built (see `blog-make-index-page'), so the Org tag machinery
 19: earns us nothing here."
 20:   (concat
 21:    "#+begin_export html\n"
 22:    (format "<h2 class=\"title\"><a href=\"%s\">%s</a></h2>\n"
 23:            (@url post) (blog--title-html (@title post)))
 24:    (format "<center>%s</center>\n" (@tags post))
 25:    (@image post (blog--url "resources/"))
 26:    "\n#+end_export\n"
 27:    "\n"
 28:    (or (@abstract post) "")
 29:    "\n"
 30:    ;; badge:… is an org-special-block-extras link type, so it must sit in
 31:    ;; Org territory ---not inside #+begin_export html, which would ship it
 32:    ;; verbatim to the HTML output.  The @@html:…@@ wrappers give us the
 33:    ;; surrounding <p> while keeping the badge: link itself as Org syntax.
 34:    (format "@@html:<p style=\"text-align:right\">@@ badge:Read|more|green|%s|read-the-docs @@html:</p>@@\n"
 35:            (@url post))
 36:    "\n@@html:<hr>@@\n"))
 37: 
 38: (defun blog--toc-block (posts)
 39:   "Return a #+begin_export html block listing every post as a linked TOC entry."
 40:   (concat
 41:    "#+begin_export html\n"
 42:    "<details id=\"articles-toc\" style=\"text-align:center;margin:1em 0\">\n"
 43:    "<summary style=\"cursor:pointer;font-size:1.1em;font-weight:bold\">Articles on this page</summary>\n"
 44:    "<ol style=\"display:inline-block;text-align:left;margin-top:0.5em\">\n"
 45:    (mapconcat (lambda (post)
 46:                 (format "<li><a href=\"%s\">%s</a></li>\n"
 47:                         (@url post) (blog--title-html (@title post))))
 48:               posts "")
 49:    "</ol>\n</details>\n"
 50:    "#+end_export\n"))
 51: 
 52: (defun blog--make-page-buffer (posts greeting export-file-name &optional rss-file)
 53:   "Return a fresh Org buffer for POSTS with GREETING, targeting EXPORT-FILE-NAME.
 54: RSS-FILE is the filename of the associated feed (\"rss.xml\" for the index,
 55: \"<tag>-rss.xml\" for a tag page).  It is surfaced as a prominent link right
 56: under the greeting so readers see it without scrolling.
 57: Caller is responsible for killing the buffer when done."
 58:   (let ((buf (generate-new-buffer " *blog-page*"))
 59:         (rss (or rss-file "rss.xml")))
 60:     (with-current-buffer buf
 61:       (insert
 62:        (format "#+EXPORT_FILE_NAME: %s\n" export-file-name)
 63:        "#+options: toc:nil title:nil html-postamble:nil broken-links:t\n"
 64:        "#+begin_export html\n"
 65:        blog-page-preamble "\n"
 66:        blog-page-header "\n"
 67:        "#+end_export\n"
 68:        "#+html: <br>\n"
 69:        greeting "\n"
 70:        (format "#+html: <p style=\"text-align:center;\"><a href=\"%s\">📡 Subscribe via RSS</a></p>\n" rss)
 71:        "#+html: <br>\n"
 72:        (blog--toc-block posts)
 73:        "#+html: <br>\n"
 74:        (mapconcat #'blog--card posts "\n")
 75:        "\n#+begin_export html\n"
 76:        "<hr> <center> <em> Thanks for reading everything! 😁 Bye! 👋 </em>"
 77:        " </center> <br/>\n"
 78:        (blog--license)
 79:        "\n#+end_export\n")
 80:       (org-mode)
 81:       ;; ospe provides the `badge:`, `doc:`, `tweet:` etc. link handlers
 82:       ;; used throughout `blog--card', and appends tooltipster CSS/JS
 83:       ;; to `org-html-head-extra' on first activation (idempotent:
 84:       ;; ospe's setup-guard wraps the injection).
 85:       (org-special-block-extras-mode 1))
 86:     buf))
 87: 
 88: (defun blog-make-index-page ()
 89:   "Assemble index.html and every tag page.
 90: 
 91: Builds one Org buffer per output file, each populated directly from
 92: the relevant subset of blog-posts ---no copy-then-delete."
 93:   (cl-flet ((export-page (posts greeting dest rss)
 94:                (let ((buf (blog--make-page-buffer posts greeting dest rss)))
 95:                  (unwind-protect
 96:                      (with-current-buffer buf (org-export-to-file 'blog-page (org-export-output-file-name ".html")))
 97:                    (with-current-buffer buf (set-buffer-modified-p nil))
 98:                    (kill-buffer buf)))))
 99:     (export-page blog-posts
100:                  (blog--greeting)
101:                  (concat blog-publish-directory "index.html")
102:                  "rss.xml")
103:     (let ((by-tag (blog--posts-by-tag)))
104:       (dolist (tag blog-tags)
105:         (message "=> Generating tag page: %s..." tag)
106:         (let ((slug (blog--tag-slug tag)))
107:           (export-page (gethash tag by-tag)
108:                        (blog--greeting tag)
109:                        (concat blog-publish-directory (concat "tag-" slug ".html"))
110:                        (concat slug "-rss.xml")))))))

2.8.16. RSS feeds: one per tag, plus a global one

Details

RSS, Really Simple Syndication, is a plain-text format a website uses to announce its new content. A reader program —Feedly, Inoreader, Newsboat, NetNewsWire, and dozens of others— subscribes to the URL of a feed and periodically refetches it; when the feed has a new <item>, the reader surfaces it as "there is a new article". The blog does not push; readers pull whenever they want.

So every time we publish, this pipeline regenerates rss.xml; the next time a reader's program polls our URL, it compares entries against what it saw last time and tells the human about any new ones. We never send email, never track subscribers, never even know who is subscribed. Publishing is just "put a new item in the feed"; the rest is the reader's problem.

Why bother at all? Because RSS is the one remaining ambient-notification mechanism for writers on the open web: no algorithm, no platform lock-in, no "follow" button owned by a company. A reader who subscribed in 2008 is still subscribed today, so long as the URL resolves. For a personal technical blog, it is the cheapest way to have an audience that returns.

We emit two flavours of feed:

  • rss.xml —every post, newest first, for the reader who wants everything we write.
  • <tag>-rss.xml —one per tag, e.g., emacs-rss.xml or arabic-rss.xml, for the reader who wants only those articles; each is linked atop its tag page.

Each item carries a <pubDate>, a <link> back to alhassy.com/<slug>, and the article's #+description as its preview text. Bodies are intentionally not embedded: we want readers to click through.

On identity and what counts as “new”. Every <item> also carries a <guid>, a globally unique identifier. Feed readers remember which guids they have already shown their user, and on every refetch compare the feed's current guids against that seen-set:

  • New guid ↦ the reader announces it as a new post.
  • Already-seen guid ↦ the reader ignores the item, even if its title, description, or pubDate changed.

Hence the guid, not the content, decides what our readers hear about. Ours is

alhassy.com/<slug>?last-updated=<MODIFIED>

or just alhassy.com/<slug> without a :MODIFIED: (#+modified: for standalone posts). Hence an edit re-announces an article exactly when its :MODIFIED: changes.

Partial-commit trick. :MODIFIED: is bumped on every save, and Magit decides the rest: stage its hunk with s to announce the edit, or unstage it with u to ship a typo fix quietly. The “should this notify?” decision lives at commit time, alongside the change. Deriving the date from git log instead would make every [typo] commit re-announce.

 1: (defun blog--rss-date (date-str)
 2:   "Format DATE-STR (an Org date string) as an RFC-822 pubDate for RSS."
 3:   (format-time-string "%a, %d %b %Y %H:%M:%S %z"
 4:                       (condition-case _
 5:                           (date-to-time date-str)
 6:                         (error (current-time)))))
 7: 
 8: (defun blog--rss-guid (post)
 9:   "Return POST's RSS <guid>.
10: 
11: If POST carries a :MODIFIED: property, the guid is the article URL with
12: a ?last-updated=<MODIFIED> query string; bumping :MODIFIED: therefore
13: re-announces the post to every subscriber's feed reader (see the
14: identity discussion in the prose above).  Without :MODIFIED:, the guid
15: is just the URL ---edits stay quiet until you stamp one."
16:   (let ((modified (map-elt post "modified")))
17:     (if modified
18:         (format "%s?last-updated=%s" (@url post) modified)
19:       (@url post))))
20: 
21: (defun blog--make-one-rss-feed (posts filename &optional channel-title)
22:   "Emit `blog-publish-directory'/FILENAME from POSTS.
23: 
24: A minimal RSS 2.0 feed ---title, link, pubDate, description per item.
25: Body is intentionally a short #+description rather than the full article
26: HTML, so readers click through to alhassy.com.  CHANNEL-TITLE defaults
27: to `blog-title'.
28: 
29: Text fields are run through `xml-escape-string' so <, >, & in titles
30: and descriptions do not break feed readers."
31:   (cl-flet ((esc (s) (xml-escape-string (or s ""))))
32:     (let ((dest  (concat blog-publish-directory filename))
33:           (title (or channel-title blog-title)))
34:       (with-temp-file dest
35:         (insert "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
36:                 "<rss version=\"2.0\">\n"
37:                 "<channel>\n"
38:                 "<title>"       (esc title) "</title>\n"
39:                 "<link>"        (esc blog-url) "</link>\n"
40:                 "<description>" (esc title) "</description>\n"
41:                 "<language>en-us</language>\n")
42:         (dolist (post posts)
43:           (insert "<item>\n"
44:                   "<title>"       (esc (@title post))       "</title>\n"
45:                   "<link>"        (esc (@url post))         "</link>\n"
46:                   "<guid>"        (esc (blog--rss-guid post)) "</guid>\n"
47:                   "<pubDate>"     (blog--rss-date (@date post)) "</pubDate>\n"
48:                   "<description>" (esc (@description post)) "</description>\n"
49:                   "</item>\n"))
50:         (insert "</channel>\n</rss>\n")))))
51: 
52: (defun blog--posts-by-tag ()
53:   "Return a hash-table mapping tag-string → list of posts carrying that tag.
54: 
55: Each post appears under every tag it declares in #+filetags.  Lookup is
56: O(1); the table mirrors `blog-posts' and should be rebuilt after every
57: `blog--refresh-posts' call ---cheapest to just call this fresh at each
58: consumer (index/RSS loops)."
59:   (let ((by-tag (make-hash-table :test #'equal)))
60:     (dolist (p blog-posts)
61:       (dolist (tag (@tag-list p))
62:         (push p (gethash tag by-tag))))
63:     (maphash (lambda (k v) (puthash k (nreverse v) by-tag)) by-tag)
64:     by-tag))
65: 
66: (defun blog--make-rss-feed ()
67:   "Emit rss.xml (all posts) plus one <tag>-rss.xml per tag."
68:   (blog--make-one-rss-feed blog-posts "rss.xml")
69:   (let ((by-tag (blog--posts-by-tag)))
70:     (dolist (tag blog-tags)
71:       (blog--make-one-rss-feed
72:        (gethash tag by-tag)
73:        (concat (blog--tag-slug tag) "-rss.xml")
74:        (format "%s — %s" blog-title tag)))))
75: 

2.8.17. blog-posts, blog-tags, and their consumers: tag & index page generation

Details
  • blog-posts —every post-alist from the .org files under ~/blog/, newest first; blog-make-index-page turns each into a card on the index and tag pages.
  • blog-tags —the sorted, unique tags of blog-posts; for tag pages and completion.

(blog--refresh-posts) sets both, at the bottom of this file and after every publish.

Every post-alist enters through blog--file-infos, so that is where we check it. date-to-time never complains: #+modified: Fri silently becomes 1970, and 2023-13-45 some other day, misplacing the article on the index and in the RSS feed. Hence an lf-assert that each date names a real calendar day; on failure it shows the source file, title, and offending date at once.

 1: (defun blog--date-p (str)
 2:   "STR ↦ non-nil iff it names a real calendar day; e.g., “2026-05-04 Mon” ↦ t, “Fri” ↦ nil, “2023-02-30” ↦ nil."
 3:   (pcase-let ((`(,_ ,_ ,_ ,day ,month ,year) (parse-time-string (or str ""))))
 4:     (and day month year (<= 1 month 12) (<= 1 day (date-days-in-month year month)))))
 5: 
 6: (defvar blog--file-infos-cache (make-hash-table :test #'equal)
 7:   "FILE ↦ (MTIME . INFOS): what `blog--file-infos' last computed for FILE.")
 8: 
 9: (defun blog--file-infos (file)
10:   "FILE ↦ its post infos (nil when unpublishable), recomputed only when FILE's mtime moves.
11: 
12: C-x C-s refreshes the registry on every save; with the cache that costs one
13: `file-attributes' per file, re-reading only the files that actually changed."
14:   (let ((mtime (file-attribute-modification-time (file-attributes file)))
15:         (hit (gethash file blog--file-infos-cache)))
16:     (if (and hit (equal mtime (car hit)))
17:         (cdr hit)
18:       (let ((infos (when (blog--publishable-p file)
19:                      ;; Each info remembers its source's path, e.g., "emacs/foo.org", for error messages.
20:                      (let ((source (file-relative-name file (expand-file-name blog-posts-directory))))
21:                        (mapcar (lambda (info) (cons (cons "source" source) info))
22:                                (if (blog--multiple-style-p file)
23:                                    (blog--info-multiple file)
24:                                  (list (blog--info file))))))))
25:         (dolist (info infos)
26:           (lf-assert (map-elt info "source") t
27:                      (@title info)           t
28:                      (@date info)            (blog--date-p it)))
29:         (puthash file (cons mtime infos) blog--file-infos-cache)
30:         infos))))
31: 
32: (defun blog--compute-posts-and-pages ()
33:   "Scan every .org file under `blog-posts-directory' and return (posts . pages).
34: 
35: Every .org file is processed uniformly ---there is no special-cased directory.
36: Container files (#+article_style: multiple) yield many post entries; standalone
37: files yield one.  A file with #+site_nav: contributes a nav-page entry instead
38: of (or in addition to) a post entry.
39: 
40: posts ---all entries, sorted newest-first.
41: pages ---site_nav entries, unsorted (used for the nav bar)."
42:   (let ((posts '())
43:         (pages '()))
44:     (dolist (file (blog--org-files))
45:       (dolist (info (blog--file-infos file))
46:         (when (map-elt info "site_nav") (push info pages))
47:         (push info posts)))
48:     (cons (sort posts (lambda (a b)
49:                         (time-less-p (date-to-time (@date b))
50:                                      (date-to-time (@date a)))))
51:           pages)))
52: 
53: (defun blog--rebuild-preamble ()
54:   "Regenerate blog-page-preamble from blog-pages.
55: Called automatically by blog--refresh-posts; also useful to call
56: interactively after editing :SITE_NAV: headings."
57:   (setq blog-page-preamble
58:         (concat
59:          "<div class=\"header\">\n"
60:          "  <a href=\"https://alhassy.github.io/\" class=\"logo\">Life & Computing Science</a>\n"
61:          "  <br/>\n"
62:          (mapconcat (lambda (p)
63:                       (format "  <a href=\"%s\">%s</a>\n" (@url p) (map-elt p "site_nav")))
64:                     blog-pages "")
65:          "</div>")))
66: 
67: (defun blog--refresh-posts ()
68:   "Recompute blog-posts, blog-pages, and blog-tags from source org files."
69:   (let ((result (blog--compute-posts-and-pages)))
70:     (setq blog-posts (car result))
71:     (setq blog-pages (cdr result))
72:     (setq blog-tags
73:           (sort (seq-uniq (mapcan #'@tag-list blog-posts)) #'string<))
74:     (blog--rebuild-preamble)))
75: 
76: (defvar blog-page-preamble ""
77:   "HTML injected at the top of every exported page: the site nav bar.
78: Set by blog--rebuild-preamble whenever blog-pages changes.")
79: 
80: (defvar blog-page-header ""
81:   "HTML injected into the <head> of every exported page: CSS, JS, MathJax, etc.
82: Set once at load time by the setq block near the blog-banner section.")
83: 
84: (defvar blog-posts nil
85:   "All post metadata, sorted newest-first. Initialized at end of file; refresh with (blog--refresh-posts).")
86: 
87: (defvar blog-pages nil
88:   "Site navigation page metadata (subtrees with :SITE_NAV: t).
89: These appear as header links on every page but not as blog post cards.
90: Initialized at end of file; refresh with (blog--refresh-posts).")
91: 
92: (defvar blog-tags nil
93:   "Tags for my blog articles. Initialized at end of file; refresh with (blog--refresh-posts).")

3. Look and Feel

What every page's <head> loads:

  • blog-page-header: our self-hosted CSS, under resources/, and <script> fragments; §3.1.
  • A floating table of contents, §3.2, and clickable headings with stable anchors.
  • MathJax, §3.4, and Arabic fonts.

3.1. Style! ✨ What do we want to be inserted into the head of every page?

Every page loads the styles and <script> fragments below; we tangle each where it belongs.

3.1.1. Style Header Elements

Firstly, we want some styling rules to be loaded.

1: (concat
2: "<meta name=\"author\" content=\"Musa Al-hassy\">
3:  <meta name=\"referrer\" content=\"no-referrer\">"
4: "<link href=\"https://alhassy.com/resources/usual-org-front-matter.css\" rel=\"stylesheet\" type=\"text/css\" />"
5: "<link href=\"https://alhassy.com/resources/org-notes-style.css\" rel=\"stylesheet\" type=\"text/css\" />"
6: "<link href=\"https://alhassy.com/resources/floating-toc.css\" rel=\"stylesheet\" type=\"text/css\" />"
7: "<link href=\"https://alhassy.com/resources/blog-banner.css\" rel=\"stylesheet\" type=\"text/css\" />"
8: "<link href=\"https://fonts.googleapis.com/css2?family=Philosopher:ital,wght@0,400;0,700;1,400;1,700&display=swap\" rel=\"stylesheet\" />"
9: "<link rel=\"icon\" href=\"https://alhassy.com/resources/favicon.png\">")
usual-org-front-matter.css
Org's default HTML front-matter styles, captured by exporting a file with C-c C-e h o and saving the CSS it produced.
org-notes-style.css
We vendor the rose-style org-notes-style, since hot-linking it from its homepage fails.
floating-toc.css
I want to have an unobtrusive floating table of contents, see §3.2.
blog-banner.css
Finally, we want a beautiful welcome mat, see §3.1.3.

3.1.2. Script Header Elements

In addition, we have two more pieces we would like to add to the header: Support for dynamic code-line highlighting, §3.1.3, and support for using LaTeX-style notation to write mathematics, §3.4. We will use a noweb-ref named my-html-header to refer to them, which are then catenated below.

Full, tangled, value of blog-page-header
(setq blog-page-header
 (concat
  ;; NOPE: org-html-head-extra  ;; Altered by 'org-special-block-extras'
  (concat
  "<meta name=\"author\" content=\"Musa Al-hassy\">
   <meta name=\"referrer\" content=\"no-referrer\">"
  "<link href=\"https://alhassy.com/resources/usual-org-front-matter.css\" rel=\"stylesheet\" type=\"text/css\" />"
  "<link href=\"https://alhassy.com/resources/org-notes-style.css\" rel=\"stylesheet\" type=\"text/css\" />"
  "<link href=\"https://alhassy.com/resources/floating-toc.css\" rel=\"stylesheet\" type=\"text/css\" />"
  "<link href=\"https://alhassy.com/resources/blog-banner.css\" rel=\"stylesheet\" type=\"text/css\" />"
  "<link href=\"https://fonts.googleapis.com/css2?family=Philosopher:ital,wght@0,400;0,700;1,400;1,700&display=swap\" rel=\"stylesheet\" />"
  "<link rel=\"icon\" href=\"https://alhassy.com/resources/favicon.png\">")
  "<script type=\"text/javascript\">
  /*
  @licstart  The following is the entire license notice for the
  JavaScript code in this tag.

  Copyright (C) 2012-2020 Free Software Foundation, Inc.

  The JavaScript code in this tag is free software: you can
  redistribute it and/or modify it under the terms of the GNU
  General Public License (GNU GPL) as published by the Free Software
  Foundation, either version 3 of the License, or (at your option)
  any later version.  The code is distributed WITHOUT ANY WARRANTY;
  without even the implied warranty of MERCHANTABILITY or FITNESS
  FOR A PARTICULAR PURPOSE.  See the GNU GPL for more details.

  As additional permission under GNU GPL version 3 section 7, you
  may distribute non-source (e.g., minimized or compacted) forms of
  that code without the copy of the GNU GPL normally required by
  section 4, provided you include this license notice and a URL
  through which recipients can access the Corresponding Source.


  @licend  The above is the entire license notice
  for the JavaScript code in this tag.
  */
  <!--/*--><![CDATA[/*><!--*/
   function CodeHighlightOn(elem, id)
   {
     var target = document.getElementById(id);
     if(null != target) {
       elem.cacheClassElem = elem.className;
       elem.cacheClassTarget = target.className;
       target.className = \"code-highlighted\";
       elem.className   = \"code-highlighted\";
     }
   }
   function CodeHighlightOff(elem, id)
   {
     var target = document.getElementById(id);
     if(elem.cacheClassElem)
       elem.className = elem.cacheClassElem;
     if(elem.cacheClassTarget)
       target.className = elem.cacheClassTarget;
   }
  /*]]>*///-->
  </script>"
  ))

3.1.3. Blog Banner and Dynamic Code Highlighting

I want to have a nice banner at the top of every page, which should link to useful parts of my blog.

Its links are data: each container subtree marked :SITE_NAV: t appears in the banner; see §.

I want to style it as follows:

  • Line 1: The banner is in a box at the top with some shadowing and centerd text using the fantasy font
  • Line 13: The blog's title is large and bold
  • Line 18: All links in the banner are black
  • Line 25: When you hover over a link, it becomes blue
CSS Details
 1: .header {
 2:   /* fantasy first (Papyrus on Chrome/Safari); Philosopher as cross-browser fallback. */
 3:   font-family: fantasy, 'Philosopher', 'Book Antiqua', Palatino, serif;
 4:   text-align: center;
 5:   overflow: hidden;
 6:   /* background-color: #f1f1f1 !important; */
 7:   /* background: #4183c4 !important; */
 8:   padding-top: 10px;
 9:   padding-bottom: 10px;
10:   box-shadow: 0 2px 10px 2px rgba(0, 0, 0, 0.2);
11: }
12: 

14:   font-size: 50px;
15:   font-weight: bold;
16: }
17: 
18: .header a {
19:   color: black;
20:   padding: 12px;
21:   text-decoration: none;
22:   font-size: 18px;
23: }
24: 
25: .header a:hover {
26:   background-color: #ddd;
27:   background-color: #fff;
28:   color: #4183c4;
29: }

Notice that as you hover over the references, such as this, the corresponding line of code is highlighted! Within a src block, one uses the switches -n -r to enable references via line numbers, then declares (ref:name) on line and refers to it by [[(name)][description]]. Org-mode by default styles such highlighting.

Dynamic Code Highlighting
 1: "<script type=\"text/javascript\">
 2: /*
 3: @licstart  The following is the entire license notice for the
 4: JavaScript code in this tag.
 5: 
 6: Copyright (C) 2012-2020 Free Software Foundation, Inc.
 7: 
 8: The JavaScript code in this tag is free software: you can
 9: redistribute it and/or modify it under the terms of the GNU
10: General Public License (GNU GPL) as published by the Free Software
11: Foundation, either version 3 of the License, or (at your option)
12: any later version.  The code is distributed WITHOUT ANY WARRANTY;
13: without even the implied warranty of MERCHANTABILITY or FITNESS
14: FOR A PARTICULAR PURPOSE.  See the GNU GPL for more details.
15: 
16: As additional permission under GNU GPL version 3 section 7, you
17: may distribute non-source (e.g., minimized or compacted) forms of
18: that code without the copy of the GNU GPL normally required by
19: section 4, provided you include this license notice and a URL
20: through which recipients can access the Corresponding Source.
21: 
22: 
23: @licend  The above is the entire license notice
24: for the JavaScript code in this tag.
25: */
26: <!--/*--><![CDATA[/*><!--*/
27:  function CodeHighlightOn(elem, id)
28:  {
29:    var target = document.getElementById(id);
30:    if(null != target) {
31:      elem.cacheClassElem = elem.className;
32:      elem.cacheClassTarget = target.className;
33:      target.className = \"code-highlighted\";
34:      elem.className   = \"code-highlighted\";
35:    }
36:  }
37:  function CodeHighlightOff(elem, id)
38:  {
39:    var target = document.getElementById(id);
40:    if(elem.cacheClassElem)
41:      elem.className = elem.cacheClassElem;
42:    if(elem.cacheClassTarget)
43:      target.className = elem.cacheClassTarget;
44:  }
45: /*]]>*///-->
46: </script>"

3.1.5. Curvy Source Blocks & Pink Inline

Details

The border-radius property defines the radius of an element's corners, we use it to make curvy looking source blocks. Its behaviour changes depending on how many arguments it is given.

  • We also style the code block's label to be curvy.
  • Both .src and pre.src:before are used by Org.
 1: .src {
 2:   border: 0px !important;
 3:   /* 50px for top-left and bottom-right corners;
 4:      20px for top-right and bottom-left cornerns. */
 5:   border-radius: 50px 20px !important;
 6: }
 7: 
 8: pre.src:before {
 9:     /* border: 0px !important; */
10:     /* background-color: inherit !important; */
11:     padding: 3px !important;
12:     border-radius: 20px 50px !important;
13:     font-weight:700
14: }
15: 
16:  /* wrap lengthy lines for code blocks */
17:  pre{white-space:pre-wrap}
18: 
19:  /* Also curvy inline code with ~ ⋯ ~ and = ⋯ = */
20:  code {
21:      /* background: Cyan !important; */
22:      background: pink !important;
23:      border-radius: 7px;
24:      /* border: 1px solid lightgrey; background: #FFFFE9; padding: 2px */
25:  }

Code such as (= 2 (+ 1 1)) now sticks out with a pink background ♥‿♥

3.1.6. Pink Tables

Details
table {
     background: pink;
     border-radius: 10px;
     /* width:90% */

     border-bottom: hidden;
     border-top: hidden;

     display: table !important;

     /* Put table in the center of the page, horizontally. */
     margin-left:auto !important;margin-right:auto !important;

     font-family:"Courier New";
     font-size:90%;
 }

 /* Styling for 't'able 'd'ata and 'h'eader elements */
 th, td {
     border: 0px solid red;
 }
Table 1: Example table
Prime 2Prime
1 2
2 4
3 8
5 32
7 128
11 2048

Table captions should be below the tables; the blog backend's :html-table-caption-above default (§The blog export backend) sees to that.

3.1.7. Let's show folded, details, regions with a nice greenish colour

Details

This is part of org-special-block-extras, and it's something like this:

details {
  padding: 1em;
  background-color: #e5f5e5;
  /* background-color: pink; */
  border-radius: 15px;
  color: hsl(157 75% 20%);
  font-size: 0.9em;
  box-shadow: 0.05em 0.1em 5px 0.01em  #00000057;
}

3.2. Ξ: Floating Table of Contents

I would like to have a table of contents that floats so that it is accessible to the reader in case they want to jump elsewhere in the document quickly —possibly going to the top of the document.


★     ★     ★

When we write #+toc: headlines 2 in our Org, HTML export produces the following.

 1: <div id="table-of-contents">
 2:   <h2>Table of Contents</h2>
 3:   <div id="text-table-of-contents">
 4:     <ul>
 5:       <li> section 1 </li>
 6:              ⋮
 7:       <li> section 𝓃 </li>
 8:     </ul>
 9:   </div>
10: </div>

Hence, we can style the table of contents by writing rules that target those id's. We use the following rules, adapted from the Worg community.

CSS for a floating TOC
 1: /*TOC inspired by https://orgmode.org/worg/ */
 2: #table-of-contents {
 3:     /* Place the toc in the top right corner */
 4:     position: fixed; right: 0em; top: 0em;
 5:     margin-top: 120px; /* offset from the top of the screen */
 6: 
 7:     /* It shrinks and grows as necessary */
 8:     padding: 0em !important;
 9:     width: auto !important;
10:     min-width: auto !important;
11: 
12:     font-size: 10pt;
13:     background: white;
14:     line-height: 12pt;
15:     text-align: right;
16: 
17:     box-shadow: 0 0 1em #777777;
18:     -webkit-box-shadow: 0 0 1em #777777;
19:     -moz-box-shadow: 0 0 1em #777777;
20:     -webkit-border-bottom-left-radius: 5px;
21:     -moz-border-radius-bottomleft: 5px;
22: 
23:     /* Ensure doesn't flow off the screen when expanded */
24:     max-height: 80%;
25:     overflow: auto;}
26: 
27: /* How big is the text "Table of Contents" and space around it */
28: #table-of-contents h2 {
29:     font-size: 13pt;
30:     max-width: 9em;
31:     border: 0;
32:     font-weight: normal;
33:     padding-left: 0.5em;
34:     padding-right: 0.5em;
35:     padding-top: 0.05em;
36:     padding-bottom: 0.05em; }
37: 
38: /* Intially have the TOC folded up; show it if the mouse hovers it */
39: #table-of-contents #text-table-of-contents {
40:     display: none;
41:     text-align: left; }
42: 
43: #table-of-contents:hover #text-table-of-contents {
44:     display: block;
45:     padding: 0.5em;
46:     margin-top: -1.5em; }

Since the table of contents floats, the phrase Table of Contents is rather 'in your face', so let's use the more subtle Greek letter Ξ.

1: (defun blog--toc-title ()
2:   "The TOC's heading: a Ξ that scrolls back to the top of the page."
3:   (s-collapse-whitespace
4:     " <a href=\"javascript:window.scrollTo(0,0)\"
5:         style=\"color: black !important; border-bottom: none !important;\"
6:         class=\"tooltip\"
7:         title=\"Go to the top of the page\">
8:       Ξ
9:     </a> "))

How did I get here?

  1. How does Org's HTML export TOCs? ⇒ org-html-toc
  2. Looking at its source, it emits <h2>Table of Contents</h2> —the phrase coming from the internal org-html--translate.
  3. The blog backend's inner-template swaps that phrase for "Ξ" —in our output alone.
  4. Joy ♥‿♥

3.3. Clickable Sections with Sensible Anchors

3.3.1. Ensuring Useful HTML Anchors

Upon HTML export, each tree heading is assigned an ID to be used for hyperlinks. Default IDs are something like org1957a9d, which does not endure the test of time: Re-export will produce a different id. Hence, on every save, we give each heading lacking a CUSTOM_ID its kebab-case slug; files not re-saved since get the same slug at export time, via blog--stable-headline-ids, so CI never emits random ids.

blog--ensure-useful-section-anchors: Run on every C-x C-s
(defun blog--ensure-useful-section-anchors (&rest _)
  "Org sections without a CUSTOM_ID are given one derived from the heading title.

Uses `blog--make-slug' so the result is lowercase kebab-case, consistent with
URL slugs.  If a collision is detected, pops a message-box and undoes.

E.g., ↯ We'll go on a ∀∃⇅ adventure
   ↦  well-go-on-a-adventure
"
  (interactive)
  (let ((ids))
    (org-map-entries
     (lambda ()
       (org-with-point-at (point)
         (let ((id (org-entry-get nil "CUSTOM_ID")))
           (unless id
             (setq id (blog--make-slug (nth 4 (org-heading-components))))
             (if (not (member id ids))
                 (push id ids)
               (message-box "Oh no, a repeated id!\n\n\t%s" id)
               (undo)
               (setq quit-flag t))
             (org-entry-put nil "CUSTOM_ID" id))))))))

;; Anchor assignment is an interactive-authoring concern ---it should only
;; happen while you can still edit the generated id, i.e. during C-x C-s
;; preview.  CI must not mutate source files, and (undo)/(message-box) don't
;; work headlessly anyway.

One may then use [[#my-custom-id]] to link to the entry with CUSTOM_ID property my-custom-id.

Aside: org-set-property, C-c C-x p, completes property names and values from those used elsewhere in the file —handy for keeping trees consistent.

3.3.2. Clickable Headlines

By default, HTML export generates ID's to headlines so they may be referenced to, but there is no convenient way to get at them to refer to a particular heading. The following spell makes each headline a link to itself.

org-html-format-headline-function
;; Src: https://writepermission.com/org-blogging-clickable-headlines.html
;; Installed as the `blog' backend's :html-format-headline-function default.
(defun blog--format-headline (todo todo-type priority text tags info)
  "Format a headline with a link to itself ---its :CUSTOM_ID:, which `blog--stable-headline-ids' guarantees."
  (let* ((id (and blog--current-headline (org-element-property :CUSTOM_ID blog--current-headline)))
         (link (if (and id (not (s-contains? "<a " text))) ;; Links cannot nest.
                   (format "<a href=\"#%s\">%s</a>" id text)
                 text)))
    (org-html-format-headline-default-function todo todo-type priority link tags info)))

blog--stable-headline-ids guarantees each heading an id. A heading that is already a link —say * [[https://example.com][Example]]— is left as is, since links cannot nest.

3.4. MathJax Support ---\(e^{i \cdot \pi} + 1 = 0\)

Org loads the MathJax display engine for mathematics whenever users write LaTeX-style math delimited by $...$ or by \[...\]. Here is an example.

Source

\[ p ⊓ q = p \quad ≡ \quad p ⊔ q = q \label{Golden-Rule}\tag{Golden-Rule}\]

Look at \ref{Golden-Rule}, it says, when specialised to numbers, /the minimum
of two items is the first precisely when the maximum of the two is the second/
---d'uh!

Result

\[ p ⊓ q = p \quad ≡ \quad p ⊔ q = q \label{Golden-Rule}\tag{Golden-Rule}\]

Look at \ref{Golden-Rule}, it says, when specialised to numbers, the minimum of two items is the first precisely when the maximum of the two is the second —d'uh!

3.4.1. Unicode Warning!

Declaring \[ℰ \tag{𝒩} \label{ℒ} \] lets \ref{ℒ} cite ℰ, displayed as 𝒩. A Unicode label ℒ breaks the link; a Unicode tag 𝒩 is harmless. By way of example, \ref{⊑-Definition} has Unicode in both its tag and label and goes nowhere, whereas \ref{Order-Definition}, labelled Order-Definition, takes you to its formula.

Source

\[ p ⊑ q \quad ≡ \quad p ⊓ q = p \tag{⊑-Definition}\label{⊑-Definition} \]

\[ p ⊑ q \quad ≡ \quad p ⊔ q = q \tag{⊑-Definition}\label{Order-Definition} \]

Result

\[ p ⊑ q \quad ≡ \quad p ⊓ q = p \tag{⊑-Definition}\label{⊑-Definition} \]

\[ p ⊑ q \quad ≡ \quad p ⊔ q = q \tag{⊑-Definition}\label{Order-Definition} \]

3.4.2. Rule Resurrection

The following rule for anchors a {⋯} resurrects \ref{} calls via MathJax —which org-notes-style kills.

a { white-space: pre !important; }

3.4.3. Making Math Stick-out with Spacing!

Notice how the math expressions stick out in these following sentences:

  1. We use \(x\) as the name of the unknown.
  2. The phrase \(∀ x • ∃ y • x 〔R〕 y\) indicates that relation \(R\) is "total".

Nice, the following pads MathJax elements so that they stand out.

3.5. Arabic Font Setup

I'd like inline Arabic to be displayed using الخط الأمیری since that's how it looks within Emacs for me. But, Arabic within tables should be displayed in a more formal font, Scheherazade, that makes it really clear where letters start and end, and where the vowels above/below letters are positioned.

Details
(defun blog--css-arabic-font-setup ()
  "Return CSS/HTML for Arabic font rendering.
For a one-off use in an article, prepend #+html: to the result."
  "
  <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Amiri'>
  <style>
     body {font-family: 'Amiri', sans-serif;}
     table {font-family:  'Scheherazade'; font-size: 105%; }
   </style>")

To understand why these styling rules work, see this website: Right-to-left Styling.


Source

For example,
+ Inline: اهلاً وسهلاً
+ Within a table:
    |  اهلاً وسهلاً |

Result

For example,

  • Inline: اهلاً وسهلاً
  • Within a table:

    اهلاً وسهلاً

As the above left source demonstrates, unless some explicit action is taken, Arabic fonts are by default rendered hideously small.

4. Publishing

The build pipeline, which CI runs on every push to master.

4.1. Actually publishing an article

We never publish by hand: we push a .org source to master, and CI runs blog-publish-all, which exports the world into public/ and deploys it to gh-pages; see §4.2.

blog-publish-all dispatches on #+article_style::

  • standalone (the default) —one .org file ↦ one HTML file.
  • multiple ---blog--publish-multiple-articles calls blog--publish-single-subtree on every non-:noexport: top-level heading.

blog--publish-single-subtree turns each heading into a synthetic temp file and exports it just as a standalone article; see §How the export pipeline connects the two styles.

(cl-defun blog--git (cmd &rest args)
  "Execute git command CMD, which may have %s placeholders whose values are positional in ARGS."
  (let ((default-directory (expand-file-name blog-posts-directory)))
    (shell-command (apply #'format (concat "git " cmd) args))))

(defun blog--multiple-style-p (&optional file)
  "Return non-nil when FILE (or the current buffer) is a multiple-style container."
  (equal "multiple" (if file
                        (blog--article-style file)
                      (blog--file-keyword nil "article_style" "standalone"))))

(defun blog--find-info-by-slug (slug infos)
  "Return the first entry in INFOS whose slug matches SLUG, or nil."
  (seq-find (lambda (a) (equal (@slug a) slug)) infos))

(defun blog--bump-modified-stamp ()
  "Update the MODIFIED timestamp in the current buffer on every C-x C-s.

For standalone-style buffers: rewrites the #+modified: file keyword if
present, or inserts it after #+date: (creating #+date: too if absent).
For multiple-style buffers: sets the :MODIFIED: property on the
top-level heading at point if the property is already present, otherwise
leaves the heading untouched ---absence is intentional (silent edits)."
  (let ((today (format-time-string "%Y-%m-%d")))
    (if (blog--multiple-style-p)
        ;; Multiple style: update :MODIFIED: on the enclosing top-level heading.
        (save-excursion
          (org-back-to-heading t)
          (when (org-entry-get (point) "MODIFIED")
            (org-entry-put (point) "MODIFIED" today)))
      ;; Standalone style: update or insert #+modified: file keyword.
      (save-excursion      
      (save-restriction
        (widen)
        (goto-char (point-min))
        (if (re-search-forward "^#\\+modified:[ ]*.*$" nil t)
            (replace-match (concat "#+modified: " today))
          ;; Insert after #+date: if present, else after the last #+keyword: block.
          (goto-char (point-min))
          (if (re-search-forward "^#\\+date:.*$" nil t)
              (end-of-line)
            (re-search-forward "^#\\+[a-zA-Z].*$" nil t)
            (while (looking-at "\n#\\+[a-zA-Z]")
              (forward-line 1)
              (end-of-line)))
          (insert "\n#+modified: " today)))))))

(defconst my/blogging-mode-map ;; Not defvar: reloading must refresh stale bindings.
  (let ((m (make-sparse-keymap)))
    (define-key m (kbd "C-x C-s")
                (lambda ()
                  (interactive)
                  ;; Container slugs first, so :TITLE: overrides win over raw heading text.
                  (blog--assign-slugs)
                  (blog--ensure-useful-section-anchors)
                  (blog--bump-modified-stamp)
                  (blog--refresh-posts)
                  (blog--validate-slugs)
                  (save-buffer)
                  (if (blog--multiple-style-p) (blog-preview-subtree) (blog--preview-standalone))))
    (define-key m (kbd "C-RET")
                (lambda ()
                  (interactive)
                  (if (blog--multiple-style-p) (blog-new-post) (blog-new-article))))
    (define-key m (kbd "C-c i i") #'blog-insert-image)
    (define-key m (kbd "C-c i s") #'blog-insert-screenshot)
    m)
  "Keymap for my/blogging-mode.")

(define-minor-mode my/blogging-mode
  "Buffer-local minor mode for editing blog articles in AlBasmala style.

Binds:
  C-x C-s  ---assign :CUSTOM_ID: slugs to fresh container headings, stamp
             section anchors, vendor :REDIRECT: sources, bump #+modified: /
             :MODIFIED: to today (if present), refresh the posts registry,
             validate slugs (kebab-case, unique), then save + live preview
  C-RET    ---new article / new post (dispatches on article style)
  C-c i i ---insert image from file (C-u to rename before committing)
  C-c i s ---take a screenshot and insert it

Publishing is not bound to a key: push your .org source to master and CI
runs `blog-publish-all' on a fresh checkout.

On activation:
  - enables org-special-block-extras-mode (badges, doc: links, tooltips)
  - switches browse-url to xwidget-webkit for in-Emacs previews (buffer-locally)
  - sets fill-column to 80 (buffer-locally), to read beside the preview

On deactivation:
  - disables org-special-block-extras-mode
  - restores this buffer's browse-url and fill-column to the global defaults"
  :lighter " Blog"
  :keymap my/blogging-mode-map
  (if my/blogging-mode
      (progn
        (org-special-block-extras-mode 1)
        (setq-local browse-url-browser-function 'xwidget-webkit-browse-url)
        ;; Narrow enough to read beside the live preview; M-q wraps to it.
        (setq-local fill-column 80)
        ;; Populate blog-posts/blog-tags now that we're actually editing ---avoids
        ;; scanning every .org on bare (require 'AlBasmala).
        (blog--refresh-posts))
    (org-special-block-extras-mode -1)
    (kill-local-variable 'browse-url-browser-function)
    (kill-local-variable 'fill-column)))

;; Modeline indicator independent of :lighter, which minions-mode hides in its menu.
(add-to-list 'mode-line-misc-info '(my/blogging-mode " ✍Blog "))


(defun blog--htmlize-subtree (heading-point slug)
  "Htmlize the subtree at HEADING-POINT in the current buffer to ~/blog/SLUG.org.html.

This produces a per-article colourised source view for container sub-articles.
We copy the subtree content to a temp buffer, narrow to the pasted content,
htmlize, and write the result."
  (save-excursion
    (goto-char heading-point)
    (org-copy-subtree))
  (with-temp-buffer
    (org-mode)
    (org-paste-subtree 1)
    (let ((buffer-file-name (concat slug ".org")))
      (blog--htmlize-to slug))))


(defun blog--publish-single-subtree (heading-point container-file info)
  "Export the subtree at HEADING-POINT in the current buffer to `blog-publish-directory'/SLUG.html.

INFO is the pre-resolved metadata alist for this heading (a single entry
from `blog--info-multiple').  The slug is read from INFO.

The subtree is copied into a temp .org file populated with synthetic
file-level keywords so that the `blog' backend exports it as a standalone article."
  (let* ((slug    (@slug info))
         (tmp-org (make-temp-file "albasmala-" nil ".org"))
         (tmp-buf (find-file-noselect tmp-org)))
    (unwind-protect
        (progn
          ;; 1. Populate temp file with synthetic file-level keywords.
          ;;    blog--info reads these via regex when the `blog' backend calls
          ;;    (blog--info buffer-file-name) during export.
          (with-current-buffer tmp-buf
            (erase-buffer)
            (insert
             "#+title: "                (cdr (assoc "title"       info)) "\n"
             "#+modified: "            (cdr (assoc "date"         info)) "\n"
             "#+fileimage: "           (cdr (assoc "image"        info)) "\n"
             "#+filetags: "            (cdr (assoc "tags"         info)) "\n"
             "#+description: "        (cdr (assoc "description"  info)) "\n"
             (if (equal "t" (cdr (assoc "draft" info))) "#+draft: t\n" "")
             ;; Synthetic overrides ---blog--info prefers these over auto-derived values.
             "#+history_url: "         (cdr (assoc "history"      info)) "\n"
             "#+htmlized_source_url: " (blog--url slug ".org.html") "\n"
             "\n")
            (save-buffer))

          ;; 2. Populate the temp file body.
          ;;
          ;;    When the heading carries a :REDIRECT: property we simply emit a
          ;;    #+include: directive pointing at the external file ---Org's own
          ;;    include machinery handles the rest during export.
          ;;
          ;;    Otherwise we copy the subtree, paste it, axe the redundant
          ;;    top-level heading line, and promote all children one level so
          ;;    blog--style-setup's "^* Abstract" search finds them correctly:
          ;;
          ;;      Before promotion   →   After
          ;;      * Article Title        (deleted)
          ;;      ** Abstract :ignore:   * Abstract :ignore:
          ;;      ** Introduction ...    * Introduction ...
          (let ((redirect (cdr (assoc "redirect" info))))
            (if redirect
                (with-current-buffer tmp-buf
                  (goto-char (point-max))
                  ;; Vendor on-the-fly so the #+include: resolves on this machine
                  ;; and on CI without mutating the source :REDIRECT: property.
                  (insert (format "#+include: \"%s\"\n"
                                  (expand-file-name
                                   (blog--vendor-one-redirect slug redirect)
                                   blog-posts-directory)))
                  (save-buffer))
              (save-excursion
                (goto-char heading-point)
                (org-copy-subtree))
              (with-current-buffer tmp-buf
                (goto-char (point-max))
                (org-paste-subtree 1)
                (goto-char (point-min))
                (when (re-search-forward "^\\* " nil t)
                  (delete-region (line-beginning-position) (line-beginning-position 2)))
                (org-map-entries #'org-promote t)
                (save-buffer))))

          ;; 3. Export with the `blog' backend.
          ;;    Write straight to public/<slug>.html, mirroring the standalone path in
          ;;    `blog-publish-all'; nothing else copies it there, so CI would never deploy it.
          (let* ((html-out (expand-file-name (concat slug ".html") blog-publish-directory)))
            (with-current-buffer tmp-buf
              (let ((blog--link-base-directory (file-name-directory (expand-file-name container-file))))
                (org-export-to-file 'blog html-out))))

          ;; 4. Per-article colourised source: htmlize the subtree narrowed copy.
          (blog--htmlize-subtree heading-point slug))

      ;; Cleanup temp files regardless of errors.
      ;; Mark the buffer unmodified before killing ---this is the only reliable way
      ;; to prevent "Buffer modified, kill anyway?" prompts regardless of what
      ;; kill-buffer-query-functions contains.
      (when (buffer-live-p tmp-buf)
        (with-current-buffer tmp-buf (set-buffer-modified-p nil))
        (kill-buffer tmp-buf))
      (when (file-exists-p tmp-org) (delete-file tmp-org)))))
(defun blog--publish-multiple-articles (container-file)
  "Publish each top-level heading of CONTAINER-FILE as a separate HTML article.

Writes the derived slug back to the heading as a :CUSTOM_ID: property so future
publishes are stable even if the heading title changes.

Returns the list of slugs that were published."
  (let* ((all-infos (blog--info-multiple container-file))
         (by-slug   (let ((h (make-hash-table :test #'equal)))
                      (dolist (i all-infos) (puthash (@slug i) i h))
                      h))
         (results   '()))
    (with-current-buffer (find-file-noselect container-file)
      (save-excursion
        (goto-char (point-min))
        (while (re-search-forward "^\\* " nil t)
          (beginning-of-line)
          (let* ((tags  (mapcar #'downcase (org-get-tags)))
                 (title (org-get-heading t t t t))
                 (slug  (org-entry-get (point) "CUSTOM_ID")))
            (unless (or (member "noexport" tags)
                        (org-element-property :commentedp (org-element-at-point)))
              (unless slug
                (user-error "Container heading %S in %s has no :CUSTOM_ID: property ---run C-x C-s on the source first"
                            title (f-filename container-file)))
              (let ((info (gethash slug by-slug)))
                (message "=> Publishing subtree: %s (%s)..." title slug)
                (blog--publish-single-subtree (point) container-file info)
                (push slug results))))
          (org-end-of-subtree t t))))
    (nreverse results)))

4.1.1. .dir-locals.el: auto-activate my/blogging-mode for all blog files

Details

Opening any .org file under ~/blog/ activates my/blogging-mode, via the .dir-locals.el below. Emacs asks once ---"Allow .dir-locals.el to run eval?"— and answering ! silences it for good.

require loads AlBasmala.el only once, so after editing this file we re-tangle and M-x load-file it; require recognises the file by the feature it provide-s.

(provide 'AlBasmala)
((org-mode . ((eval . (progn
                        (require 'AlBasmala (expand-file-name "AlBasmala"
                                              (locate-dominating-file default-directory ".dir-locals.el")))
                        (my/blogging-mode 1))))))

4.2. Deployment via CI

We keep two branches: master holds the sources, and CI owns all the HTML.

master branch                      gh-pages branch
──────────────────────────────     ─────────────────────────────────
foo.org         (source)      ──►  foo.html     (alhassy.com/foo)
AlBasmala.org   (source)   CI      index.html
resources/                    ──►  tag-emacs.html
AlBasmala.el                       tag-programming-language.html
(NO .html files here)              rss.xml
                                   resources/  ...
  • master holds only sources: .org files, resources/, and config.
  • Every push makes CI export every post, index.html, the tag-*.html pages, and rss.xml into public/, alongside a copy of resources/.
  • peaceiris/actions-gh-pages force-pushes public/ to gh-pages, which GitHub Pages serves at the root; so URLs are flat: alhassy.com/foo.

CI rebuilds everything, every run. A fresh checkout has no public/, so blog-publish-all exports every publishable .org —no mtimes, no diffing— and the deploy overwrites gh-pages wholesale.

Hence a check either runs before any HTML is written, or not at all. blog--validate-slugs runs on C-x C-s, where slug clashes are cheapest to fix, and first thing in blog-publish-all; so a pushed-but-never-previewed edit fails the build rather than silently overwriting one page with another.

  • What is deployed? Browse the gh-pages branch; it is exactly what alhassy.com serves.
  • One-time setup: in the repo's Settings → Pages, set Source to "Deploy from a branch", with branch gh-pages and folder /. Otherwise GitHub Pages serves master, where no HTML lives.
  • Timing: a run takes ~2 minutes, of which exporting is ~30 s; the rest is provisioning a runner and installing Emacs. Public repos get unlimited Actions minutes, so this costs nothing.

4.2.1. Asset sync

Details

HTML refers to assets by relative paths, such as resources/foo.css, which resolve both locally and at alhassy.com/resources/. So blog--sync-assets copies resources/, along with two root-level stragglers, into public/.

(defun blog--sync-assets ()
  "Copy static assets into blog-publish-directory so relative HTML paths resolve.

Called at the end of `blog-publish-all' so that public/ is self-contained and
can be deployed as-is to gh-pages without the master-branch source tree."
  (let ((dist (file-name-as-directory (expand-file-name blog-publish-directory))))
    (make-directory dist t)
    ;; Required: resources/ must exist ---missing it means a broken site.
    (dolist (asset '("resources"))
      (let ((src (expand-file-name asset blog-posts-directory)))
        (unless (file-exists-p src)
          (user-error "blog--sync-assets: required asset %s missing at %s" asset src))
        (copy-directory src (expand-file-name asset dist) t t t)))
    ;; Optional: nice-to-haves ---silent skip if absent.
    (dolist (asset '("readremaining.js-readremainingjs" "floating-toc.css"))
      (let ((src (expand-file-name asset blog-posts-directory)))
        (when (file-exists-p src)
          (if (file-directory-p src)
              (copy-directory src (expand-file-name asset dist) t t t)
            (copy-file src (expand-file-name asset dist) t)))))))

4.2.2. blog-publish-all: CI entry point

Details

blog-publish-all is what CI calls; locally, it is a full dry-run before pushing.

(defun blog--publishable-p (file)
  "Return non-nil if FILE is an article that should be published.
A file is publishable when it has any of:
  - #+date:                     ---standalone post
  - #+article_style: multiple   ---container of subtree articles
  - #+site_nav:                 ---top-level nav page (e.g. about.org)

Fragments that are #+include'd into articles but never published on
their own (e.g. MathJaxPreamble.org) must opt out explicitly with

  #+exclude_from_publish: t

Any other .org file missing /both/ a publish marker and the opt-out
signals a (user-error) ---we do not silently skip files that just
forgot their #+date:."
  (with-temp-buffer
    (insert-file-contents file)
    (cl-flet ((kw (k) (blog--file-keyword nil k)))
      (cond
       ((kw "exclude_from_publish") nil)
       ((kw "modified") t)
       ((equal (kw "article_style") "multiple") t)
       ((kw "site_nav") t)
       (t (user-error
           "%s has no #+modified:, #+article_style: multiple, or #+site_nav: ---add one, or #+exclude_from_publish: t if this is an include-only fragment"
           (f-filename file)))))))

(defun blog-publish-all ()
  "Batch-publish every article and regenerate the index.  The sole CI entry point.

Exports all posts → public/, rebuilds index + tag pages + RSS, copies static
assets.  public/ is then deployed by CI to gh-pages so URLs stay flat:
alhassy.com/foo not alhassy.com/public/foo.

Dispatches on #+article_style: per file:
  standalone (default) → one .org → one HTML file,
  multiple            → one subtree → one HTML file (via `blog--publish-multiple-articles')."
  ;; CI passes relative directories, e.g., "./public/"; pin them now, else a nested article's
  ;; buffer would resolve them against its own subdirectory.
  (let ((blog-posts-directory   (expand-file-name blog-posts-directory))
        (blog-publish-directory (file-name-as-directory (expand-file-name blog-publish-directory))))
    (make-directory blog-publish-directory t)
    (blog--refresh-posts)
    (blog--validate-slugs)
    (dolist (f (seq-filter #'blog--publishable-p (blog--org-files)))
      (with-current-buffer (find-file-noselect f)
        ;; badge:/doc:/tweet: links in articles are registered as Org link types
        ;; by org-special-block-extras-mode.  It's a buffer-local minor mode, so
        ;; turn it on per buffer ---without it those links leak verbatim to HTML.
        (org-special-block-extras-mode 1)
        (if (blog--multiple-style-p)
            (progn
              (message "=> Exporting all articles from %s..." (f-base f))
              (blog--vendor-redirects)
              (blog--publish-multiple-articles f))
          (let ((target (expand-file-name (concat (f-base f) ".html") blog-publish-directory)))
            ;; Export directly to public/<base>.html so that #+export_file_name:
            ;; entries pointing at ~/blog/ (valid locally, absent on CI) never
            ;; become the output destination.
            (org-export-to-file 'blog target)))))
    (blog-make-index-page)
    (blog--make-rss-feed)
    (blog--sync-assets)))

4.2.3. Media insertion: images and screenshots

Details

C-c i i inserts an image file, and C-c i s a screenshot; each lands in resources/, git add-ed.

(defun blog-insert-image (file)
  "Copy FILE into ~/blog/resources/, git-add it, and insert an Org link, relative to this file, at point.

With a \\[universal-argument] prefix, prompts for a new filename after the
default name is pre-filled so you can rename the resource before committing."
  (interactive "fImage file: ")
  (let* ((default-name (f-filename file))
         (dest-name (if current-prefix-arg
                        (read-string "Name for image (with extension): " default-name)
                      default-name))
         (dest (expand-file-name dest-name (expand-file-name "resources/" blog-posts-directory))))
    (copy-file file dest t)
    (blog--git "add resources/%s" dest-name)
    (insert (format "[[file:%s]]" (file-relative-name dest)))  ; Relative to this file; `blog--link' re-roots it.
    (org-display-inline-images nil t (line-beginning-position) (line-end-position))))

(defun blog-insert-screenshot ()
  "Take an interactive screenshot, move it to ~/blog/resources/, git-add, insert link.

Uses macOS screencapture -i (crosshair selector).  After the screenshot is taken
you are prompted for a meaningful name; the timestamp default is just a fallback."
  (interactive)
  (let* ((tmp (make-temp-file "blog-screenshot-" nil ".png")))
    (shell-command (format "screencapture -i %s" (shell-quote-argument tmp)))
    (if (not (file-exists-p tmp))
        (message "Screenshot cancelled.")
      (let* ((default-name (format "screenshot-%s.png" (format-time-string "%Y%m%d-%H%M%S")))
             (dest-name (read-string "Name for screenshot (with extension): " default-name))
             (dest (expand-file-name dest-name (expand-file-name "resources/" blog-posts-directory))))
        (rename-file tmp dest t)
        (blog--git "add resources/%s" dest-name)
        (insert (format "[[file:%s]]" (file-relative-name dest)))  ; Relative to this file; `blog--link' re-roots it.
        (org-display-inline-images nil t (line-beginning-position) (line-end-position))))))

4.2.4. Validation: unique slugs

Details

It enforces §the slug rules on every save and first thing in blog-publish-all.

(defun blog--validate-slugs ()
  "Signal a `user-error' when a container slug isn't lower-case kebab-case, or two articles share a slug.

Scans blog-posts, which covers both standalone and container articles.  The
effective slug is the :CUSTOM_ID: (container subtrees) or the file basename
(standalone articles); both land at the same URL, so both must be globally
unique.  Only :CUSTOM_ID: slugs must be kebab-case: a standalone basename
like =PathCat= is a long-published URL."
  (let ((seen (make-hash-table :test #'equal)))
    (dolist (p blog-posts)
      (let* ((slug   (or (@slug p) (@file p)))  ; effective URL slug
             (source (format "\"%s\" (%s)"
                             (or (map-elt p "title") slug)
                             (or (map-elt p "source") slug)))
             (prior  (gethash slug seen)))
        (when (and (@slug p) (not (equal slug (blog--make-slug slug))))
          (user-error "Slug \"%s\" of %s isn't lower-case kebab-case; use :CUSTOM_ID: %s"
                      slug source (blog--make-slug slug)))
        (when prior
          ;; Subdirectories don't reach the flat site: emacs/foo.org and life/foo.org both claim foo.html.
          (user-error "Duplicate slug \"%s\":\n  already claimed by %s\n  also claimed by %s\n  %s"
                      slug prior source
                      (if (@slug p) "Change one of the :CUSTOM_ID: properties."
                        "Rename one of the files; subdirectories don't count, the site is flat.")))
        (puthash slug source seen)))))

4.2.5. CI workflow: .github/workflows/ci.yml

Details

This block is the workflow: it tangles to .github/workflows/ci.yml, so any CI workaround sits beside its explanation. One build job

  1. installs our packages, upgrading a cached org-special-block-extras lacking org-deftag, which gives us the :details: tag;
  2. tangles this file and loads AlBasmala.el;
  3. runs blog-publish-all; and
  4. on pushes to master, force-pushes public/ to gh-pages.
name: blog CI

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
  workflow_dispatch:

# Serialise deploys so two quick master pushes don't race on the
# gh-pages force-push.  Queue rather than cancel ---every deploy lands.
concurrency:
  group: pages
  cancel-in-progress: false

# Build .org → public/, force-push to gh-pages on master.  See README.
jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Set up Emacs
        uses: purcell/setup-emacs@v8.0
        with:
          version: 30.1

      - name: Cache Emacs packages
        uses: actions/cache@v4
        with:
          path: ~/.emacs.d/elpa
          key: elpa-${{ runner.os }}-emacs30.1-${{ hashFiles('.github/workflows/ci.yml') }}

      - name: Install Emacs package dependencies
        run: |
          emacs --batch \
            --eval "(require 'package)" \
            --eval "(setq package-archives '((\"melpa\" . \"https://melpa.org/packages/\")))" \
            --eval "(package-initialize)" \
            --eval "(unless (file-exists-p (expand-file-name \"archives/melpa\" package-user-dir)) (package-refresh-contents))" \
            --eval "(dolist (p '(dash f s htmlize org-special-block-extras)) (unless (package-installed-p p) (package-install p)))" \
            --eval "(unless (locate-library \"org-deftag\") (package-refresh-contents) (package-upgrade 'org-special-block-extras))"

      - name: Build all articles
        run: |
          mkdir -p ~/.emacs.d
          printf '* Mini-tutorial-on-Org-mode\n:PROPERTIES:\n:CUSTOM_ID: Mini-tutorial-on-Org-mode\n:END:\n' > ~/.emacs.d/init.org
          emacs --batch \
            --eval "(require 'package)" \
            --eval "(setq package-archives '((\"melpa\" . \"https://melpa.org/packages/\")))" \
            --eval "(package-initialize)" \
            --load ox-extra.el \
            --eval "(defun not-currently-working-with (&optional _) \"\")" \
            --eval "(defun currently-working-with (&optional _) \"\")" \
            --eval "(setq org-confirm-babel-evaluate nil)" \
            --eval "(setq org-export-with-broken-links t)" \
            --eval "(setq blog-publish-directory \"./public/\")" \
            --eval "(setq blog-posts-directory \"./\")" \
            --eval "(require 'ob-tangle)" \
            --eval "(org-babel-tangle-file \"AlBasmala.org\")" \
            --eval "(load-file \"AlBasmala.el\")" \
            --eval "(blog-publish-all)"

      - name: Deploy public/ to gh-pages
        if: github.ref == 'refs/heads/master' && github.event_name == 'push'
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public
          publish_branch: gh-pages
          cname: alhassy.com

5. The name: al-bas-mala

The prefix al is the Arabic definite particle which may correspond to English's the; whereas basmala refers to a beginning.

That is, this is a variation on the traditional "hello world" ;-)

6. Appendices

Appendix 𝐈 Using a Custom Domain: alhassy.com

Details
  1. Go to your repo: https://github.com/alhassy/alhassy.github.io/settings/pages
  2. Add a Custom Domain such as an "apex domain" like alhassy.com (or a www domain like www.alhassy.com)
    • Apex domains require A records to be setup on your DNS provider.
    • www domains require CNAME records.
    • On my DNS provider, I setup both: That way, with or without www., people will arrive at my blog.
  3. Go to your DNS provider, and add two records

    Type Name Priority Content TTL
    CNAME www 0 alhassy.github.io 14400
    CNAME @ 0 alhassy.github.io 14400
  4. In a terminal, run: dig www.alhassy.com +nostats +nocomments +nocmd
    • The www. is intentional.
  5. You should see something like:

    www.alhassy.com.	14400	IN	CNAME	alhassy.github.io.
    alhassy.github.io.	3600	IN	A	185.199.111.153
    alhassy.github.io.	3600	IN	A	185.199.108.153
    alhassy.github.io.	3600	IN	A	185.199.109.153
    alhassy.github.io.	3600	IN	A	185.199.110.153
    

    This says that it may take 3600 seconds, or 1hour, for the redirect of alhassy.com to alhassy.github.io to be completed. It may take longer; keep reading.

  6. In your DNS provider, add 4 records, one for each IP Address you got from the dig command. (These addresses are also on the official Github docs).

    Type Name Priority Content TTL
    A @ 0 185.199.108.153 14400
    A @ 0 185.199.109.153 14400
    A @ 0 185.199.110.153 14400
    A @ 0 185.199.111.153 14400
  7. Run dig alhassy.com +noall +answer -t A and ensure it points to these IP addresses.
    • Notice the lack of a www.
  8. Open an incognito browser, private browsing session, and navigate to alhassy.com. a. If this redirects to your alhassy.github.io blog, then joy!

    • If the redirect does not happen in your non-incognito browser, just clear your browsing history and try again.

    b. Otherwise, it may take some time (something like 1/2hour to 3 days) for the DNS propagation to be completed.

    • You can check the progress by using a service like this.
    • It took me about 1/2hour for the URL to redirect to my github.io blog.

Further resources:

6.1. Appendix 𝐈𝐈 Stuff that really belongs in org-special-block-extras

6.2. Appendix 𝐈𝐈𝐈 Speculative TODO-s

Notes, not code: nothing below tangles.

  1. Support :HTMLEXPORTMETHOD: property for trees, eg if i have an article I want exported via ox-jeopardy, then I can I can use that?
  2. (setq org-export-with-broken-links t) ;; TODO: Should avoid this! It's set in the CI!
  3. Add a link to Mathematics for Computing, or axe that repo and just make it a blog article now? lol I think Example 1 has an arithmetic error!?
  4. Add a link to https://alhassy.com/next-700-module-systems/proposal/thesis-proposal.pdf and to the actual thesis.
  5. Why not move the Org sources into a src directory, beside resources and public?
  6. TODO: The sections that have been tangled from my init: Just move that code here. That code was written with my blog in mind, and so it deserves to be here, and not in my init.
  7. TODO: Look at stuff in my init and see what there is specifically serving my blog; and move that here. Can always link to AlBasmala.html.
  8. Super simple validation: The number of blog posts can only increase.
  9. TODO: When I'm done, look around and ensure there are no 'TODO's; or if there are any, move them to my todo.org::blog::Ideas section!
  10. font for links


    [Editor Remark:

    Before we move on, I'd like to have heavy red font for links.

    HERE PLS

    But this causes the table of contents to be red, which I dislike ლ(ಠ益ಠ)ლ

    ]

  11. wrt floating toc:

    [Strange:

    If I zoom in over 100% in my browser, the toc disappears until I zoom out.

    ]

  12. TODO: Make the floating toc "Ξ" be aware of links… … so that "blog-new-article" renders nicely in the floating doc!
  13. TODO: Find-replace all px with percentages, then ensure things look good!?
  14. Column Width

    When blogging, I see live HTML previews whenever I save thanks to blog-preview. As such, I cannot have my lines being too wide. I'll settle for a modest 80 columns —set buffer-locally by my/blogging-mode. Then M-q will format my paragraphs to wrap nicely into this 80-column width.

    (When I'm coding with a single open window, I usually use a comfortable 120 columns.)

  15. Required loads

Generated by Emacs and Org-mode (•̀ᴗ•́)و
Creative Commons License
Life & Computing Science by Musa Al-hassy is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License