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

Ξ
- 1. The name: al-bas-mala
- 2. Typical workflow: How do I publish an article?
- 3. Anatomy of an article
- 4. Writing articles:
my/blogging-mode- 4.1. The mode and its keys
- 4.2.
.dir-locals.el: auto-activatemy/blogging-modefor all blog files - 4.3. Seamlessly Previewing Articles within Emacs 😲
- 4.4. blog-new-article: Helper function to make a new article
- 4.5. blog-new-post: Insert a heading skeleton in a multiple-style container file
- 4.6. Media insertion: images and screenshots
- 5. From Org files to post-alists
- 5.1. Basic facts (global variables) of my blog
- 5.2. blog–info: Core helper to get the plist/JSON metadata about each post
- 5.3. Convenient accessor methods: Given a JSON hashmap, get the specified key values
- 5.4. blog–article-style, blog–make-slug: Utilities for the
multiplearticle style - 5.5. blog–info-subtree, blog–info-multiple: Extract metadata from container headings
- 5.6. blog-posts, blog-tags, and their consumers: tag & index page generation
- 5.7. Validation: unique slugs
- 6. Exporting an article: the
blogbackend- 6.1. blog–header-html: the site nav, styles, and title atop every article
- 6.2. The
blogexport backend: header, tags, image, badges, and footer - 6.3. Article Footer: HTMLized Source and Git History
- 6.4. Colourised source: one consistent theme
- 6.5. blog–htmlize-file: Generate an htmlized version of a given source file; return an HTML badge linking to the colourised file
- 6.6. blog–license: HTML for Creative Commons Attribution-ShareAlike 3.0 Unported License
- 6.7. blog–read-remaining-js: HTML to use ReadRemaining.js
- 6.8. Link types and glossaries shared by every article
- 7. Look and Feel
- 8. Index, tag pages, and RSS feeds
- 9. Publishing
- 10. Dependencies
- 11. Appendices
How my blog is setup (•̀ᴗ•́)و
Here are some notable features of my blog.
- Org-mode, a rich markup, to write articles ♥‿♥
- Tags and per-tag RSS feeds —§RSS feeds
- A nice blog banner —§7.1.3
- Dynamically highlighting code from references in prose —§7.1.3
- Tooltips, folded regions, and badges ---
- Overall nice looking HTML style ---org-notes-style
- Beautiful math using LaTeX notation, \(\forall \phi ⇒ \exists \phi\) —§7.4
- A floating, yet unobtrusive, table of contents —§7.2
- Articles have images and abstracts, shown as cards on the index —§5.3.3, §8.1
- Each article links its colourised Org source and its Github history, right under its image —§6.3
- Article titles may contain arbitrary
@@html: ...@@yet still render nicely in both the frame tab and page title, thanks toblog--sanitise-title-js—§the header. - A reading-time clock that adjusts as you scroll —§6.3
- Style inline code and tables —§7.1.5
- Unfurling links —§3
1. 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" ;-)
2. Typical workflow: How do I publish an article?
- Open an Org-mode buffer —or call blog-new-article
Make a new article for my blog; prompting for the necessary ingredients. If the filename entered already exists, we simply write to it. The user notices this and picks a new name. This sets up a new article based on existing tags and posts. + Use C-SPC to select multiple tag items Under ‘my/blogging-mode’, every save, C-x C-s, live-previews the article.
(or blog-new-postInsert 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.
). - Invoke 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, kebab-case existing ids (and their [[#id]] links), vendor :REDIRECT: sources, bump #+modified: / :MODIFIED: to today (if present), refresh the posts registry, validate slugs (kebab-case, unique), then save + live preview C-u C-x C-s ---likewise, but repeated section ids become <slug>--placeholder-N; every later save reminds us of them until we rename them 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 This is a minor mode. If called interactively, toggle the ‘My/Blogging mode’ mode. If the prefix argument is positive, enable the mode, and if it is zero or negative, disable the mode. If called from Lisp, toggle the mode if ARG is ‘toggle’. Enable the mode if ARG is nil, omitted, or is a positive number. Disable the mode if ARG is a negative number. To check whether the minor mode is enabled in the current buffer, evaluate the variable ‘my/blogging-mode’. The mode’s hook is called both when the mode is enabled and when it is disabled. Key Binding ------------------------------------------------------------------------------- C-RET #<interpreted-function 6BA> C-x C-s #<interpreted-function 3BF> C-c i i blog-insert-image C-c i s blog-insert-screenshotto get live WYSIWYG in an adjacent buffer after every save C-x C-sC-x C-s ∷ save-buffer Save current buffer in visited file if modified. Variations are described below. By default, makes the previous version into a backup file if previously requested or if this is the first save. Prefixed with one C-u, marks this version to become a backup when the next save is done. Prefixed with two C-u’s, makes the previous version into a backup file. Prefixed with three C-u’s, marks this version to become a backup when the next save is done, and makes the previous version into a backup file. With a numeric prefix argument of 0, never make the previous version into a backup file. Note that the various variables that control backups, such as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’, and ‘backup-inhibited’, to name just the more popular ones, still control whether a backup will actually be produced, even when you invoke this command prefixed with two or three C-u’s. If a file’s name is FOO, the names of its numbered backup versions are FOO.~i~ for various integers i. A non-numbered backup file is called FOO~. Numeric backups (rather than FOO~) will be made if value of ‘version-control’ is not the atom ‘never’ and either there are already numeric versions of the file being backed up, or ‘version-control’ is non-nil. We don’t want excessive versions piling up, so there are variables ‘kept-old-versions’, which tells Emacs how many oldest versions to keep, and ‘kept-new-versions’, which tells how many newest versions to keep. Defaults are 2 old versions and 2 new. ‘dired-kept-versions’ controls Dired’s clean-directory (.) command. If ‘delete-old-versions’ is nil, system will query user before trimming versions. Otherwise it does it silently. If ‘vc-make-backup-files’ is nil, which is the default, no backup files are made for files managed by version control. (This is because the version control system itself records previous versions.) See the subroutine ‘basic-save-buffer’ for more information. (fn &optional ARG)
.- The mode is auto-enabled in
~/blog/via.dir-locals.el.
- The mode is auto-enabled in
Until content:
- ✏️ Write, write, and write!
- E.g., insert an image with C-c i i (from file) or C-c i s (screenshot)
- 👀 C-x C-s
C-x C-s ∷ save-buffer Save current buffer in visited file if modified. Variations are described below. By default, makes the previous version into a backup file if previously requested or if this is the first save. Prefixed with one C-u, marks this version to become a backup when the next save is done. Prefixed with two C-u’s, makes the previous version into a backup file. Prefixed with three C-u’s, marks this version to become a backup when the next save is done, and makes the previous version into a backup file. With a numeric prefix argument of 0, never make the previous version into a backup file. Note that the various variables that control backups, such as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’, and ‘backup-inhibited’, to name just the more popular ones, still control whether a backup will actually be produced, even when you invoke this command prefixed with two or three C-u’s. If a file’s name is FOO, the names of its numbered backup versions are FOO.~i~ for various integers i. A non-numbered backup file is called FOO~. Numeric backups (rather than FOO~) will be made if value of ‘version-control’ is not the atom ‘never’ and either there are already numeric versions of the file being backed up, or ‘version-control’ is non-nil. We don’t want excessive versions piling up, so there are variables ‘kept-old-versions’, which tells Emacs how many oldest versions to keep, and ‘kept-new-versions’, which tells how many newest versions to keep. Defaults are 2 old versions and 2 new. ‘dired-kept-versions’ controls Dired’s clean-directory (.) command. If ‘delete-old-versions’ is nil, system will query user before trimming versions. Otherwise it does it silently. If ‘vc-make-backup-files’ is nil, which is the default, no backup files are made for files managed by version control. (This is because the version control system itself records previous versions.) See the subroutine ‘basic-save-buffer’ for more information. (fn &optional ARG)
to save & preview
🤔 Consider using C-x n s
C-x n s ∷ org-narrow-to-subtree Narrow buffer to the current subtree. Use the command ‘C-x n w’ to see the whole buffer again. With optional argument ELEMENT narrow to subtree around ELEMENT. (fn &optional ELEMENT)
, or C-x n nC-x n n ∷ narrow-to-region Restrict editing in this buffer to the current region. The rest of the text becomes temporarily invisible and untouchable but is not deleted; if you save the buffer in a file, the invisible text is included in the file. C-x n w makes all visible again. See also ‘save-restriction’. When calling from Lisp, pass two arguments START and END: positions (integers or markers) bounding the text that should remain visible. However, when restrictions have been set by ‘with-restriction’ with a label, ‘narrow-to-region’ can be used only within the limits of these restrictions. If the START or END arguments are outside these limits, the corresponding limit set by ‘with-restriction’ is used instead of the argument. To gain access to other portions of the buffer, use ‘without-restriction’ with the same label. (fn START END)
, to focus your attention on a particular section, thereby dramatically increasing the speed at which the preview renders. 💥- ✏️ Write, write, and write!
- 🤖
git pushwhen you're done —CI runsblog-publish-alland deployspublic/togh-pages, index, RSS, and tag pages included.- Changes go live about 2 minutes after the push; see §Deployment via CI.
Behind the scenes, a save and a push travel as follows.
.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
The rest of this article follows one article along that journey:
- §Anatomy of an article —what we write.
- §Writing articles ---
my/blogging-mode, its live preview, and helpers for new articles and images. - §From Org files to post-alists —how every
.orgfile becomes metadata. - §Exporting an article —the
blogbackend: header, badges, footer. - §Look and Feel —the CSS and JS every page loads.
- §Index, tag pages, and RSS —the pages built from all articles at once.
- §Publishing ---
blog-publish-alland CI's deploy togh-pages. - §Dependencies —what
AlBasmala.elloads first, and why.
2.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.1.1. Mini-tutorial-on-Org-mode
3. Anatomy of an article
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.
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.
3.1. The Two Article Styles: standalone vs multiple
We support two ways to author blog posts, selectable per-file via #+article_style:.
3.1.1. standalone (the default)
One .org file = one article; its file-level keywords (#+title:, #+filetags:, …)
supply the metadata.
3.1.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, unlisted;blog-new-postadds it, and we axe it when ready. See §drafts.:noexport:orCOMMENT→ 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.1.3. 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-sC-x C-s ∷ save-buffer Save current buffer in visited file if modified. Variations are described below. By default, makes the previous version into a backup file if previously requested or if this is the first save. Prefixed with one C-u, marks this version to become a backup when the next save is done. Prefixed with two C-u’s, makes the previous version into a backup file. Prefixed with three C-u’s, marks this version to become a backup when the next save is done, and makes the previous version into a backup file. With a numeric prefix argument of 0, never make the previous version into a backup file. Note that the various variables that control backups, such as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’, and ‘backup-inhibited’, to name just the more popular ones, still control whether a backup will actually be produced, even when you invoke this command prefixed with two or three C-u’s. If a file’s name is FOO, the names of its numbered backup versions are FOO.~i~ for various integers i. A non-numbered backup file is called FOO~. Numeric backups (rather than FOO~) will be made if value of ‘version-control’ is not the atom ‘never’ and either there are already numeric versions of the file being backed up, or ‘version-control’ is non-nil. We don’t want excessive versions piling up, so there are variables ‘kept-old-versions’, which tells Emacs how many oldest versions to keep, and ‘kept-new-versions’, which tells how many newest versions to keep. Defaults are 2 old versions and 2 new. ‘dired-kept-versions’ controls Dired’s clean-directory (.) command. If ‘delete-old-versions’ is nil, system will query user before trimming versions. Otherwise it does it silently. If ‘vc-make-backup-files’ is nil, which is the default, no backup files are made for files managed by version control. (This is because the version control system itself records previous versions.) See the subroutine ‘basic-save-buffer’ for more information. (fn &optional ARG)
stamps a missing one withblog--make-slugof the title, so later title edits never shift the URL; an existing id that isn't lower-case kebab-case —e.g., a:REDIRECT:'sNext_Steps— becomes so,next-steps, along with every[[#Next_Steps]]link to it. blog--validate-slugsrefuses 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.
3.2. Drafts: published, yet unlisted
A draft is an article we're still mulling over: it is published at its URL, so we can read it on the go, yet it's unlisted —absent from the index, the tag pages, and the RSS feeds. An article is a draft when any of these hold:
- its container heading is tagged
:draft:—asblog-new-postdoes— or carries:DRAFT: t; - its standalone file says
#+draft: t; or - its file lives under
~/blog/draft/, by way of exampledraft/half-baked-idea.org; a container there makes every one of its headings a draft.
@draft? is the one predicate, and blog--listed-posts the one filter, behind the index, tag pages,
and RSS; blog-posts keeps drafts, so blog--validate-slugs still catches a draft whose slug clashes
with a published article. A draft's page also opens with a banner saying so, and carries
<meta name"robots" content="noindex">= lest a stray link let search engines list it.
To see every draft at once, visit alhassy.com/drafts: blog-make-index-page builds it from @draft?
posts, as cards like the index's. It is itself unlisted —no nav link, no feed, and noindex.
Publishing is then just axing the marker —or moving the file out of draft/— and saving; the
bumped :MODIFIED: makes it new on the index and in RSS that day. Mind that unlisted isn't secret:
the .org source is on GitHub, and its colourised copy is deployed beside the article.
3.3. The <code>#+beginabstract</code> is an Org-mode Special Block
orange
An article's Abstract section previews it on the index page; see §3 for a template.
The block below adapts the docstring examples of org-defblockDeclare a new special block, and link, in the style of ‘defun’.
To remove support for a block NAME, simply use ‘org-undefblock’.
A full featured example is at the end of this documentation string.
This is an anaphoric macro that provides export support for
special blocks *and* links named NAME. Just as an Org-mode
src-block consumes as main argument the language for the src
block, our special blocks too consume a MAIN-ARG; it may be a
symbol or a cons-list consisting of a symbolic name (with which
to refer to the main argument in the definition of the block)
followed by a default value, then, optionally, any information
for a one-time setup of the associated link type.
The main arg may be a sequence of symbols separated by spaces,
and a few punctuation with the exception of comma ‘,’ since it is
a special Lisp operator. In doubt, enclose the main arg in
quotes.
Then, just as Org-mode src blocks consume key-value pairs, our
special blocks consume a number of keyword arguments.
Together, the main and keyword arguments form KWDS, written as a
‘cl-defun’ arglist; by way of example,
(&optional (title "Details") &key (color "green") style)
declares main argument TITLE, defaulting to "Details", and
keyword arguments COLOR and STYLE. As with ‘cl-defun’, a header
line’s unknown keywords are an export-time error unless KWDS writes
‘&allow-other-keys’; so, () takes no arguments at all.
KWDS may also carry options, which are not arguments:
+ ‘:block nil’ declines block support: #+begin_NAME is left to Org, yet the
link and tag still work, through ‘org-block/NAME’.
+ ‘:link nil’ declines the link type NAME:; ‘:link t’ makes it; and ‘:link
[:face … :display …]’ makes it with that ‘org-deflink’ display, which sees
the link’s arglist (label &optional description backend); e.g.,
[:face =’(:foreground "red")] shows such links in red.
+ ‘:tag nil’ declines the tag: otherwise a section tagged :NAME: exports as a
NAME block around its text; the tag’s underscore-separated items form the
main argument, and the section’s properties supply keyword arguments by
name ---every property bar Org’s own, when the block allows other keys.
E.g., "* Stuck? :details_Hint:" folds its text away under the
title Hint. See ‘org--defblock-section-to-block’.
A block cannot hold a heading, so ‘:tag [:subsections MODE]’ says what becomes
of a tagged section’s subsections: ‘ignore’ leaves them outside the block, as
‘:tag t’ does; ‘error’ refuses a section that has any; and ‘recurse’ wraps
each subsection’s text in a block of its own.
The link and tag default to ‘org-defblock-links-and-tags’; for just one of
them, see ‘org-deflink’ and ‘org-deftag’.
+ ‘:edit-indirect-mode MODE’ makes C-c =’ edit the block in MODE.
+ ‘:latex-head (ITEM …)’ is what a LaTeX document using the block, or its link, needs in its preamble, added once:
a package name, or any preamble line; e.g., unquoted,
:latex-head ("tcolorbox" "\\newtcolorbox{aside}{colback=gray!5}")
Name a package, "tcolorbox", rather than write "\\usepackage{tcolorbox}": a name is left be when the document
loads it already, options and all; a line, only when the document has that very line. See ‘org--load-latex-head’.
+ ‘:html-head (ITEM …)’ is what an HTML page using the block needs in its <head>, added once: a string of HTML, a
symbol whose value is one, or ‘mathjax’ for Org’s own MathJax setup; e.g., unquoted,
:html-head (mathjax my-html-head "<style>.aside {color: gray}</style>")
A symbol shares one head among many blocks, as ‘org--tooltip-html-head’ serves every tooltip. See
‘org--load-html-head’.
+ ‘:fontify [:begin STRING :end STRING :face FACE]’ says how the block looks in Org buffers;
e.g., [:begin "Proof." :end "⏹" :face =’italic] shows “#+begin_proof” as “Proof.”,
“#+end_proof” as “⏹”, and the contents in italics too. Moreover, :keywords =’((REGEXP GROUP FACE) …) shows
each REGEXP match in the contents in FACE; e.g., the ‘math’ block’s “blue:<<Fold>>” shows as a tag, in blue.
And :language "latex" shows the contents as a #+begin_src latex block’s. Each part is optional; see
‘org--fontify-blocks’.
+ ‘:hover STRING’ is prose that eldoc shows, on hovering the block’s #+begin_ line, beneath its docstring’s
first line; e.g., a tip too long for that line. See ‘org--special-block-eldoc’.
+ ‘:manual URL’ is where the block is documented, which eldoc offers as “See the manual”; a URL of #ANCHOR is
that section of ‘org-special-block-extras-manual’; e.g., :manual "#math".
After that is an optional DOCSTRING, a familar feature of DEFUN.
The docstring is displayed as part of the tooltip for the
produced link type.
Finally, the BODY is a (sequence of) Lisp forms ---no progn
needed--- that sees KWDS’ arguments, and whatever else it asks for
after &context in KWDS:
(org-defblock shout (&optional (title "Hey") &context backend contents)
(format (if (eq backend =’html) "<b>%s</b>: %s" "%s: %s") title (upcase contents)))
On offer, as ‘org--block-context-keys’ lists them:
+ backend ⇒ the current export backend; e.g., html, or latex.
+ contents ⇒ the block’s contents, Org-parsed; i.e., Org-markup is
acknowledged ---or the description clause of a link.
+ raw-contents ⇒ the contents unparsed, for the rare body that wants them.
+ args ⇒ the plist of every keyword argument the block was given.
+ link? ⇒ whether we are rendering a link’s description, not a block.
+ anchor ⇒ the block’s #+name:, else nil.
This is dependency injection, by keyword: every block is offered the
whole record, and takes only what it names. Hence BODY sees no name
it did not ask for, so nothing of ours captures a variable of its own;
e.g., a main argument named ‘contents’ is just that. An item we offer
in due time is invisible to the blocks that do not ask for it; and an
item not on offer, such as a misspelt contnets, is an error at once.
An item may be bound under another name, ((:ITEM VAR)), as when KWDS
already uses ITEM’s name; an item named both ways is an error:
(org-defblock quip (&optional contents &context ((:contents text)))
(format "%s, said of %s" contents text)) ;; #+begin_quip Ha! ⇒ “Ha!, said of …”
See “Keyword Parameters” in Practical Common Lisp, a gentle tour of
such renaming: https://gigamonkeys.com/book/functions.html
Finally, this macro exposes two functions:
+ ORG-EXPORT: Wrap the argument in an export block for the current backend.
+ ORG-PARSE: This should ONLY be called within an ORG-EXPORT call,
to escape text to Org, and out of the export block.
⇄ We use “@@html:⋯:@@” when altering CONTENTS, but otherwise use raw HTML *around* CONTENTS.
⇄ For example: (format "<div>%s</div>" (s-replace "#+columnbreak:" "@@html:<hr>@@" contents))
----------------------------------------------------------------------
The relationship between links and special blocks:
[ [type:label][description]]
≈
#+begin_type label
description
#+end_type
----------------------------------------------------------------------
Example declaration, with all possible features shown:
;; We can use variable values when defining new blocks
(setq angry-red ’(:foreground "red" :weight bold))
(org-defblock remark
(&optional (editor "Editor Remark") &key (color "red") (signoff "")
:link [:face angry-red] &context backend contents)
"Top level (HTML & LaTeX) editorial remarks; in Emacs they’re angry red."
(format (if (equal backend ’html)
"<strong style=\"color: %s;\">⟦%s: %s%s⟧</strong>"
"{\color{%s}\bfseries %s: %s%s}")
color editor contents signoff))
;; Personalised defaults, without changing the definition, as Babel’s for a src block of language remark;
;; or, per section, :header-args:remark: :editor "Jasim Jameson" in a heading’s drawer.
(setq org-babel-default-header-args:remark =’((:editor . "Jasim Jameson") (:signoff . "( Aim for success! )")))
Three example uses:
;; ⟨0⟩ As a special blocks with arguments given.
#+begin_remark Bobbert Barakallah :signoff "Thank-you for pointing this out!" :color green
I was trying to explain that ${large (n × (n + 1) over 2}$ is always an integer.
#+end_remark
;; ⟨1⟩ As a terse link, using default values for the args.
;; Notice that Org-mode formatting is recoqgnised even in links.
[ [remark:Jasim Jameson][Why are you taking about "$\mathsf{even}$" here?]]
;; ⟨2⟩ So terse that no editor name is provided.
[ [remark:][Please improve your transition sentences.]]
;; ⟨★⟩ Unlike 0, examples 1 and 2 will have the default SIGNOFF
;; catenated as well as the default red color.
(fn NAME KWDS &optional DOCSTRING &rest BODY).
On export, blog--style-setup tags the * Abstract heading :abstract:, and the block's tag boxes
that section's text; [:subsections ignore] leaves any subsection outside the box, rather than
choking on a heading the box cannot hold.
;; A section tagged :abstract: is boxed too; its subsections stay outside the box, as a block cannot hold a heading.
(org-defblock abstract (:tag [:subsections ignore] &context contents)
"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:
In this article, we learn to have fun!
3.4. 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:
* Holy Books: Quran & Bible look-ups in Org-mode :emacs:
:PROPERTIES:
:DATE: <2021-10-25>
:CUSTOM_ID: holy-books-in-org-mode
:REDIRECT: https://raw.githubusercontent.com/alhassy/holy-books/main/holy-books.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.
3.4.1. The repo owns the .org; the blog points at it
By way of example, holy-books is a literate Emacs package: holy-books.org tangles to
holy-books.el, which MELPA ships. Its write-up belongs on the blog, where friends who'd
never browse GitHub find it via the index, tag pages, and RSS; its code belongs in its own
repo, where people star it, fork it, and file issues.
github.com/alhassy/holy-books blog (alhassy.github.io) ├── holy-books.org ← single source ├── projects.org ├── holy-books.el ← tangled, committed * Holy Books :emacs: ├── tests.el :REDIRECT: https://raw…/holy-books.org ├── README.md ← tiny, hand-written * Repl-Driven Dev :emacs: └── images/ :REDIRECT: …
Why not the other way round, with the blog owning the .org and tangling into
~/holy-books/? Then the :tangle paths are absolute, the repo's CI can't re-tangle, and
every pull request against the .el is silently clobbered by our next tangle. Contributors
fork one repo, and should find the literate source beside the code.
projects.orgis a container with one heading per repo, each a:REDIRECT:. Its first heading carries:SITE_NAV: Projects, so one URL —linked from every page— lists everything; it's the link we send friends. Adding a repo is a five-line heading.README.mdin each repo is hand-written and tiny: a badge row, one GIF, ause-packagesnippet, a short table of the main commands, and "Full write-up →". It changes rarely, so generating it from the.org(ox-mdon a:readme:subtree) is a pipeline we don't need; in due time, if the command table starts drifting, we revisit.;; URL:in the package header points at the GitHub repo, not the blog, so bug reports land where the code lives.- Per-repo GitHub Pages, like
alhassy.com/holy-books/, are forthcoming retirees: each write-up should live at one canonical URL. Until then, a redirected article's slug must differ from its repo's name, else the project site shadows it —hence:CUSTOM_ID: holy-books-in-org-mode, notholy-books.
Migrating a repo is then a checklist:
- Un-
COMMENTits heading inprojects.org, with a:CUSTOM_ID:unlike the repo's name. - Tag repo-only plumbing —the package preamble, the test suite, the CI YAML—
:noexport:: it still tangles, but never reaches readers. - Make the tangle a CI-checked fixpoint, lest someone patch the
.eldirectly: CI re-tangles the.organd fails ifgit diff --exit-codefinds the committed.eldiffers; seeholy-books.org's "Testing" heading. - C-x C-s
C-x C-s ∷ save-buffer Save current buffer in visited file if modified. Variations are described below. By default, makes the previous version into a backup file if previously requested or if this is the first save. Prefixed with one C-u, marks this version to become a backup when the next save is done. Prefixed with two C-u’s, makes the previous version into a backup file. Prefixed with three C-u’s, marks this version to become a backup when the next save is done, and makes the previous version into a backup file. With a numeric prefix argument of 0, never make the previous version into a backup file. Note that the various variables that control backups, such as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’, and ‘backup-inhibited’, to name just the more popular ones, still control whether a backup will actually be produced, even when you invoke this command prefixed with two or three C-u’s. If a file’s name is FOO, the names of its numbered backup versions are FOO.~i~ for various integers i. A non-numbered backup file is called FOO~. Numeric backups (rather than FOO~) will be made if value of ‘version-control’ is not the atom ‘never’ and either there are already numeric versions of the file being backed up, or ‘version-control’ is non-nil. We don’t want excessive versions piling up, so there are variables ‘kept-old-versions’, which tells Emacs how many oldest versions to keep, and ‘kept-new-versions’, which tells how many newest versions to keep. Defaults are 2 old versions and 2 new. ‘dired-kept-versions’ controls Dired’s clean-directory (.) command. If ‘delete-old-versions’ is nil, system will query user before trimming versions. Otherwise it does it silently. If ‘vc-make-backup-files’ is nil, which is the default, no backup files are made for files managed by version control. (This is because the version control system itself records previous versions.) See the subroutine ‘basic-save-buffer’ for more information. (fn &optional ARG)
inprojects.organd eyeball the preview.
3.4.2. A REDIRECT tracks master: vendoring, freshness, and staleness
A :REDIRECT: of ~/.emacs.d/init.org works on our laptop and fails on CI, which has no
~/.emacs.d/; a URL fails whenever the network does. So every publish vendors the
source —copied or fetched— into the git-tracked resources/redirects/<slug>.org, and
includes that.
- The property is never rewritten.
:REDIRECT: https://raw.githubusercontent.com/alhassy/holy-books/main/holy-books.orgstays as written, so every publish re-fetches the tip of the repo's default branch ---master, ormainfor younger repos; the URL names whichever the repo uses. - The vendored copy is a fallback, not a source. When the source is unreachable we reuse
last time's copy, with a message; only a source that is unreachable and never vendored
is an error. An HTTP error counts as unreachable ---
url-copy-filewould happily vendor GitHub's404: Not Foundpage as our article! So we fetch withcurl --fail, which also sidesteps url.el's cache and its bodiless304 Not Modifiedreplies.
Hence how far an article can lag its source:
| Source | Laptop preview | CI |
|---|---|---|
| URL | fresh fetch | fresh fetch |
| local path | fresh copy | the last committed copy |
- A URL article lags its repo until the next blog build. Pushes to the blog trigger
builds, but pushes to
holy-booksdon't; so CI also rebuilds daily —seeschedule:in §the CI workflow— bounding the lag at a day.workflow_dispatchis there for the impatient: we force a rebuild by Run workflow on the blog's Actions tab, or in a terminal bygh workflow run ci.yml -R alhassy/alhassy.github.io. - A local-path article lags until we publish from the laptop and commit the refreshed
copy; the diff of
resources/redirects/<slug>.orgis then a terse "what changed upstream" view. - A renamed branch or file makes the fetch 404: we keep publishing the stale copy, and the
message tells us to fix the
:REDIRECT:.
To cut a URL article's lag from a day to minutes, its repo dispatches our build whenever it
pushes to master. Git has no post-push hook, so the repo's .git/hooks/pre-push waits, in
the background, until the remote's master is the commit pushed —a rejected push thus
dispatches nothing— then runs gh workflow run, logging to .git/publish.log. We copy it
verbatim into each repo a :REDIRECT: points at, then chmod +x .git/hooks/pre-push; e.g.,
org-special-block-extras has it.
#!/bin/sh
# After a push to master lands, rebuild alhassy.com, which publishes this repo's Org via a :REDIRECT:;
# see AlBasmala.org §“A REDIRECT tracks master”. Git has no post-push hook, so we wait, in the background,
# until the remote's master is the commit we pushed; a rejected push so triggers nothing.
remote="$1"
log="$(git rev-parse --absolute-git-dir)/publish.log"
while read -r _local_ref local_sha remote_ref _remote_sha; do
[ "$remote_ref" = refs/heads/master ] || continue
echo "pre-push: alhassy.com rebuilds once ${local_sha%"${local_sha#???????}"} lands; see .git/publish.log" >&2
(
for _ in $(seq 60); do
if [ "$(git ls-remote "$remote" refs/heads/master | cut -f1)" = "$local_sha" ]; then
echo "$(date) $local_sha: $(gh workflow run ci.yml -R alhassy/alhassy.github.io 2>&1 || echo FAILED)"
exit
fi
sleep 2
done
echo "$(date) $local_sha: never landed; no rebuild"
) >>"$log" 2>&1 </dev/null &
done
exit 0
A repo whose default branch is main says so in its refs/heads/master, twice. Hooks aren't
versioned, so a fresh clone needs the copy anew. A .github/workflows/ file dispatching on
push would be versioned, but a workflow's own GITHUB_TOKEN cannot dispatch another repo's, so
each repo would need a personal access token as a secret; for a handful of repos on one laptop,
the hook is the lighter setup. Either way, raw.githubusercontent.com caches files for up to 5
minutes, so a rebuild that quick may yet fetch the old copy.
3.4.3. Making a source include-ready
Three things that are right in the repo are wrong in the blog, so blog--prepare-redirect
fixes them in the vendored copy:
- Relative links.
[[file:images/short_example.png]]is relative to the repo, but once vendored it'd resolve againstresources/redirects/. For a URL source we re-root it at the URL's directory, ↦[[https://raw.githubusercontent.com/alhassy/holy-books/main/images/short_example.png]], which Org still inlines as an image. A local-path source keeps its links; in due time, we may resolve them against the source's git remote. - File-level keywords. The temp file already opens with the heading's
#+title:and friends; an included#+title: holy-bookswould be concatenated onto ours, and#+options: toc:nilwould hide our Ξ table of contents. So we axe#+title:,#+subtitle:,#+author:,#+date:,#+options:, and#+export_file_name:; and#+toc:, since Ξ already is one. #+fileimage:. The repo declares its own article image; e.g.,#+fileimage: images/short_example.png 350 253, which M-x my/blogging-mode previews in the repo use too. For a URL source we re-root its path like any relative link, andblog--info-subtreereads it from the vendored copy whenever the heading lacks an:IMAGE:of its own; so the index, tag pages, and RSS agree with the article. Henceblog-publish-allvendors every redirect before building the posts registry, lest CI's index show the image as of our last commit.
Lookalikes inside source blocks are left alone, since org-element only reports real
keywords and links.
A package's write-up is demonstrative of the package: holy-books.org is full of
quran:1:1 and basmala: links, which only export once holy-books.el has registered them.
So a heading's :LOAD: names the package's .el —a URL or a path, alongside its
:REDIRECT:— which we vendor verbatim to resources/redirects/<slug>.el, with the same
fallback, and load before exporting. CI thus needs no per-package install step, though
the package's own dependencies must already be among CI's packages. (Loading fetched
code is trust: whoever can push to that repo can run code in our CI. It's our repo.)
We skip the load when the package is already a feature, named after the .el;
e.g., holy-books.el ↦ holy-books. CI starts afresh, so it always loads; but on our
laptop, previewing projects.org would otherwise clobber the working copy we're hacking
on with the GitHub tip —whose bugs we may have just fixed, unpushed!
* Holy Books: Quran & Bible look-ups in Org-mode :emacs:
:PROPERTIES:
:CUSTOM_ID: holy-books-in-org-mode
:REDIRECT: https://raw.githubusercontent.com/alhassy/holy-books/main/holy-books.org
:LOAD: https://raw.githubusercontent.com/alhassy/holy-books/main/holy-books.el
:END:
A single .el isn't always enough: one that reads files beside it —say, a link type whose
examples live in the repo's examples/*.org— finds none when fetched alone. So a
:LOAD: that's neither a URL nor an absolute path is relative to the :REDIRECT:'s GitHub
repo, which we shallow-clone to the git-ignored .redirect-clones/<repo>/ —refetched each
publish, with last time's clone as the offline fallback— and load from there; e.g.,
:LOAD: lisp/examples.el. The directory is hidden, so it's neither scanned for articles
nor copied to the site, where a nested .git would break GitHub Pages. One-off link types
thus stay with their article, rather than piling up in AlBasmala.org.
(org-special-block-extras.org needs none: its :demo blocks carry their own snapshots,
and CI installs the package itself.)
Finally, a redirected article on GitHub opens with a banner inviting readers to star the
repo or discuss it in its issues —at the top, where it's seen, rather than after a long
read, where it's missed. Its History badge points at the repo's log for the file, not at
projects.org's, and its Source badge colourises the vendored file, not the five-line
heading.
3.4.4. A REDIRECT to a container
org-special-block-extras.org is a 9,000-line manual; as one article, it buries the block
types under the pitch and the developer's guide under both. So it declares
#+article_style: multiple, just like projects.org, and each public top-level heading
becomes its own article; its one projects.org heading still points at the whole file.
#+article_style: multiple
* 🧙♂️ /Write Once, Export Everywhere/: Say Hello to =org-special-block-extras=
:PROPERTIES:
:CUSTOM_ID: org-special-block-extras
:TITLE: org-special-block-extras: Write Once, Export Everywhere
:DESCRIPTION: A unified interface for special blocks and links: defblock.
:END:
* Block and link types provided by this package
:PROPERTIES:
:CUSTOM_ID: org-special-block-extras-blocks-and-links
⋮
- Registry.
blog--info-multipleswaps such a heading's info for its vendored source's article infos, viablog--redirected-container-infos; each inherits the heading's:REDIRECT:,:LOAD:, history, draft-ness, and tags, and remembers its slug as"redirect-slug", which names the vendored file. An:ignore:heading —say,HTML & LaTeX Setup— and the footnote section are never articles. - Body. An article is
#+include: "vendored.org::#slug" :only-contents t :minlevel 1, after#+setupfile: vendored.org, so the container's#+macro:=s and =header-args—notably:eval never-export— still apply. - Cross-article links.
[[#Boxed-Text]]in the pitch targets a section of another article, soblog--prepare-redirectrewrites it to[[https://alhassy.com/org-special-block-extras-blocks-and-links#Boxed-Text][…]]; a link within its own article stays put.
The first article reuses the heading's slug, so alhassy.com/org-special-block-extras still
lands on the pitch.
(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--github-raw-parts (redirect)
"REDIRECT ↦ (OWNER REPO BRANCH PATH) when it's a raw.githubusercontent.com URL, else nil."
(when (string-match "\\`https://raw\\.githubusercontent\\.com/\\([^/]+\\)/\\([^/]+\\)/\\([^/]+\\)/\\(.+\\)\\'"
redirect)
(mapcar (lambda (i) (match-string i redirect)) '(1 2 3 4))))
(defun blog--redirect-history-url (redirect)
"REDIRECT ↦ the GitHub commit history of its file, or nil when it isn't on GitHub."
(pcase (blog--github-raw-parts redirect)
(`(,owner ,repo ,branch ,path) (format "https://github.com/%s/%s/commits/%s/%s" owner repo branch path))))
(defun blog--redirect-banner (redirect)
"REDIRECT ↦ Org text inviting readers to star or discuss its GitHub repo; \"\" when it isn't on GitHub."
(pcase (blog--github-raw-parts redirect)
(`(,owner ,repo ,_ ,_)
(format (concat "#+begin_center\n"
"⭐ The code lives at [[https://github.com/%1$s/%2$s][github.com/%1$s/%2$s]]"
" ---star it, or [[https://github.com/%1$s/%2$s/issues][discuss it there]]!"
" github-stars:%1$s/%2$s\n"
"#+end_center\n\n")
owner repo))
(_ "")))
(defun blog--redirect-keyword (slug key)
"(SLUG, KEY) ↦ the value of #+KEY: in SLUG's vendored :REDIRECT: source, or nil; e.g., (\"holy-books-in-org-mode\", \"fileimage\")."
(let ((file (expand-file-name (concat blog-redirects-subdir slug ".org") blog-posts-directory)))
(when (file-exists-p file)
(blog--file-keyword file key))))
(defun blog--fetch-url (url file)
"Write URL's body to FILE; an HTTP error is an error, lest we vendor a “404: Not Found” page."
;; Not url.el: its cache sends If-Modified-Since, and a bodiless 304 isn't an article.
(unless (zerop (call-process "curl" nil nil nil "--fail" "--silent" "--show-error" "--location"
"--max-time" "30" "--output" file url))
(error "curl couldn't fetch %s" url)))
(defconst blog--redirect-axed-keywords '("TITLE" "SUBTITLE" "AUTHOR" "DATE" "OPTIONS" "EXPORT_FILE_NAME" "TOC")
"A :REDIRECT: source's file-level keywords that would clash with its article's synthetic ones.")
(defun blog--prepare-redirect (file redirect)
"Make FILE, vendored from REDIRECT, include-ready; e.g., when REDIRECT is a URL, file:images/x.png ↦ URL-dir/images/x.png.
Axes `blog--redirect-axed-keywords', re-roots relative file links and #+fileimage: at REDIRECT's
directory when it's a URL, and, in a container, points [[#id]] at the article holding id when
that's another one; see §“Making a source include-ready”."
(with-temp-buffer
(insert-file-contents file)
(delay-mode-hooks (org-mode))
(let* ((base (and (blog--url-p redirect) (file-name-directory redirect)))
(tree (org-element-parse-buffer))
;; The :CUSTOM_ID: of EL's top-level heading, i.e., its article's slug in a container.
(article (lambda (el) (when-let ((top (car (last (seq-filter (lambda (e) (eq 'headline (org-element-type e)))
(org-element-lineage el))))))
(org-element-property :CUSTOM_ID top))))
;; id ↦ its heading's article, when this source is a container; e.g., "Boxed-Text" ↦ "org-special-block-extras-blocks-and-links".
(homes (when (blog--multiple-style-p file)
(org-element-map tree 'headline
(lambda (h) (when-let ((id (org-element-property :CUSTOM_ID h)))
(cons id (if (= 1 (org-element-property :level h)) id (funcall article h)))))))))
;; Edit back to front, so the positions of earlier elements stay valid.
(dolist (el (nreverse (org-element-map tree '(keyword link) #'identity)))
(let ((beg (org-element-property :begin el))
(end (org-element-property :end el))
(type (org-element-property :type el))
(path (org-element-property :path el)))
(cond
((member (org-element-property :key el) blog--redirect-axed-keywords)
(delete-region beg end))
((and base (equal (org-element-property :key el) "FILEIMAGE"))
(let* ((image (car (split-string (org-element-property :value el))))
;; Expand before searching: `url-expand-file-name' clobbers the match data.
(url (and image (not (blog--url-p image)) (not (file-name-absolute-p image))
(url-expand-file-name image base))))
(when url
(goto-char beg)
(search-forward image end)
(replace-match url t t))))
((and (equal type "custom-id") (map-elt homes path)
(not (equal (map-elt homes path) (funcall article el))))
;; Another article's section: link to that article, keeping our description or using its id.
(let ((desc (if (org-element-property :contents-begin el)
(buffer-substring (org-element-property :contents-begin el) (org-element-property :contents-end el))
path)))
(delete-region beg (- end (org-element-property :post-blank el)))
(goto-char beg)
(insert (format "[[%s#%s][%s]]" (blog--url (map-elt homes path)) path desc))))
((and base (equal type "file") (not (file-name-absolute-p path)))
;; Expand before searching: `url-expand-file-name' clobbers the match data.
(let ((url (url-expand-file-name path base)))
(goto-char beg)
(search-forward (org-element-property :raw-link el) end)
(replace-match url t t)))))))
(write-region nil nil file nil 'silent)))
(defun blog--vendor-one-redirect (slug redirect &optional ext)
"(SLUG, REDIRECT, EXT) ↦ `blog-redirects-subdir'/SLUG.EXT, freshly copied or fetched from REDIRECT.
EXT defaults to \".org\", and only then is the copy made include-ready; a :LOAD:'s \".el\" is
vendored verbatim. When REDIRECT is unreachable ---no network, or CI lacking ~/.emacs.d/--- we
keep last time's copy, and only signal an error when there is none."
(let* ((vendored (concat blog-redirects-subdir slug (or ext ".org")))
(dest (expand-file-name vendored blog-posts-directory))
(tmp (make-temp-file "albasmala-redirect-" nil ".org")))
(make-directory (file-name-directory dest) t)
(condition-case err
(progn
(if (blog--url-p redirect)
(blog--fetch-url redirect tmp)
(copy-file (expand-file-name redirect) tmp t))
(unless ext (blog--prepare-redirect tmp redirect))
(rename-file tmp dest t)
;; From the blog's root: our caller may be a temp buffer, outside the repo.
(let ((default-directory (expand-file-name blog-posts-directory)))
(call-process "git" nil nil nil "add" dest)))
(error
(when (file-exists-p tmp) (delete-file tmp))
(unless (file-exists-p dest)
(user-error "REDIRECT %s is unreachable and was never vendored: %s" redirect (error-message-string err)))
(message "=> REDIRECT %s is unreachable (%s); reusing the vendored %s"
redirect (error-message-string err) vendored)))
vendored))
(defun blog--clone-redirect-repo (redirect)
"REDIRECT ↦ the directory of a fresh shallow clone of its GitHub repo, .redirect-clones/REPO/.
When the fetch fails ---say, no network--- we keep last time's clone, and only signal an error when there is none."
(pcase-let* ((`(,owner ,repo ,branch ,_) (or (blog--github-raw-parts redirect)
(user-error "A repo-relative :LOAD: needs a GitHub :REDIRECT:, not %s" redirect)))
(dir (file-name-as-directory (expand-file-name (concat ".redirect-clones/" repo) blog-posts-directory)))
(url (format "https://github.com/%s/%s.git" owner repo))
(git (lambda (&rest args) (zerop (apply #'call-process "git" nil nil nil args)))))
(cond
((not (file-directory-p dir))
(make-directory (file-name-directory (directory-file-name dir)) t)
(unless (funcall git "clone" "--quiet" "--depth" "1" "--branch" branch url dir)
(user-error "Couldn't clone %s, and there's no earlier clone at %s" url dir)))
((not (and (funcall git "-C" dir "fetch" "--quiet" "--depth" "1" "origin" branch)
(funcall git "-C" dir "reset" "--quiet" "--hard" "FETCH_HEAD")))
(message "=> Couldn't fetch %s; reusing the clone at %s" url dir)))
dir))
(defun blog--redirect-load-file (slug redirect lib)
"(SLUG, REDIRECT, LIB) ↦ the file to `load' for :LOAD: LIB; a URL or absolute path is vendored, else LIB is relative to REDIRECT's repo."
(if (or (blog--url-p lib) (file-name-absolute-p lib))
(expand-file-name (blog--vendor-one-redirect slug lib ".el") blog-posts-directory)
(expand-file-name lib (blog--clone-redirect-repo redirect))))
(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)))))))
4. Writing articles: my/blogging-mode
Opening any .org file under ~/blog/ turns on my/blogging-mode; from then on C-x C-sC-x C-s ∷ save-buffer
Save current buffer in visited file if modified.
Variations are described below.
By default, makes the previous version into a backup file
if previously requested or if this is the first save.
Prefixed with one C-u, marks this version
to become a backup when the next save is done.
Prefixed with two C-u’s,
makes the previous version into a backup file.
Prefixed with three C-u’s, marks this version
to become a backup when the next save is done,
and makes the previous version into a backup file.
With a numeric prefix argument of 0, never make the previous version
into a backup file.
Note that the various variables that control backups, such
as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’,
and ‘backup-inhibited’, to name just the more popular ones, still
control whether a backup will actually be produced, even when you
invoke this command prefixed with two or three C-u’s.
If a file’s name is FOO, the names of its numbered backup versions are
FOO.~i~ for various integers i. A non-numbered backup file is called FOO~.
Numeric backups (rather than FOO~) will be made if value of
‘version-control’ is not the atom ‘never’ and either there are already
numeric versions of the file being backed up, or ‘version-control’ is
non-nil.
We don’t want excessive versions piling up, so there are variables
‘kept-old-versions’, which tells Emacs how many oldest versions to keep,
and ‘kept-new-versions’, which tells how many newest versions to keep.
Defaults are 2 old versions and 2 new.
‘dired-kept-versions’ controls Dired’s clean-directory (.) command.
If ‘delete-old-versions’ is nil, system will query user
before trimming versions. Otherwise it does it silently.
If ‘vc-make-backup-files’ is nil, which is the default,
no backup files are made for files managed by version control.
(This is because the version control system itself records previous versions.)
See the subroutine ‘basic-save-buffer’ for more information.
(fn &optional ARG)
is not
merely “save” but “stamp, validate, save, and preview”.
4.1. The mode and its keys
Every save assigns missing slugs and section anchors, bumps the modified-stamp, refreshes the post registry, validates slugs, and only then saves and previews. C-RET starts a new article, and C-c i i / C-c i s insert images.
(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 current-prefix-arg)
(blog--bump-modified-stamp)
;; Blog-wide checks are for blog files; a slug clash in ~/blog mustn't block saving another repo's .org.
(when (file-in-directory-p buffer-file-name blog-posts-directory)
(blog--refresh-posts)
(blog--validate-slugs))
(save-buffer)
(if (blog--multiple-style-p) (blog--preview-subtree) (blog--preview-standalone))
;; Last, so the preview's own messages don't bury the reminder.
(blog--warn-placeholder-ids)))
(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, kebab-case existing ids (and their [[#id]] links), vendor :REDIRECT: sources, bump #+modified: /
:MODIFIED: to today (if present), refresh the posts registry,
validate slugs (kebab-case, unique), then save + live preview
C-u C-x C-s ---likewise, but repeated section ids become <slug>--placeholder-N;
every later save reminds us of them until we rename them
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 "))
The modified-stamp decides whether RSS readers hear about an edit; see §RSS feeds.
(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)))))))
4.2. .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.3. Seamlessly Previewing Articles within Emacs 😲
Every save, C-x C-sC-x C-s ∷ save-buffer
Save current buffer in visited file if modified.
Variations are described below.
By default, makes the previous version into a backup file
if previously requested or if this is the first save.
Prefixed with one C-u, marks this version
to become a backup when the next save is done.
Prefixed with two C-u’s,
makes the previous version into a backup file.
Prefixed with three C-u’s, marks this version
to become a backup when the next save is done,
and makes the previous version into a backup file.
With a numeric prefix argument of 0, never make the previous version
into a backup file.
Note that the various variables that control backups, such
as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’,
and ‘backup-inhibited’, to name just the more popular ones, still
control whether a backup will actually be produced, even when you
invoke this command prefixed with two or three C-u’s.
If a file’s name is FOO, the names of its numbered backup versions are
FOO.~i~ for various integers i. A non-numbered backup file is called FOO~.
Numeric backups (rather than FOO~) will be made if value of
‘version-control’ is not the atom ‘never’ and either there are already
numeric versions of the file being backed up, or ‘version-control’ is
non-nil.
We don’t want excessive versions piling up, so there are variables
‘kept-old-versions’, which tells Emacs how many oldest versions to keep,
and ‘kept-new-versions’, which tells how many newest versions to keep.
Defaults are 2 old versions and 2 new.
‘dired-kept-versions’ controls Dired’s clean-directory (.) command.
If ‘delete-old-versions’ is nil, system will query user
before trimming versions. Otherwise it does it silently.
If ‘vc-make-backup-files’ is nil, which is the default,
no backup files are made for files managed by version control.
(This is because the version control system itself records previous versions.)
See the subroutine ‘basic-save-buffer’ for more information.
(fn &optional ARG)
, shows the final product —image, abstract, header, footer, and all
styles— in a JavaScript-capable browser within Emacs; ≈WYSIWYG≈.
Under my/blogging-mode, C-x C-sC-x C-s ∷ save-buffer
Save current buffer in visited file if modified.
Variations are described below.
By default, makes the previous version into a backup file
if previously requested or if this is the first save.
Prefixed with one C-u, marks this version
to become a backup when the next save is done.
Prefixed with two C-u’s,
makes the previous version into a backup file.
Prefixed with three C-u’s, marks this version
to become a backup when the next save is done,
and makes the previous version into a backup file.
With a numeric prefix argument of 0, never make the previous version
into a backup file.
Note that the various variables that control backups, such
as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’,
and ‘backup-inhibited’, to name just the more popular ones, still
control whether a backup will actually be produced, even when you
invoke this command prefixed with two or three C-u’s.
If a file’s name is FOO, the names of its numbered backup versions are
FOO.~i~ for various integers i. A non-numbered backup file is called FOO~.
Numeric backups (rather than FOO~) will be made if value of
‘version-control’ is not the atom ‘never’ and either there are already
numeric versions of the file being backed up, or ‘version-control’ is
non-nil.
We don’t want excessive versions piling up, so there are variables
‘kept-old-versions’, which tells Emacs how many oldest versions to keep,
and ‘kept-new-versions’, which tells how many newest versions to keep.
Defaults are 2 old versions and 2 new.
‘dired-kept-versions’ controls Dired’s clean-directory (.) command.
If ‘delete-old-versions’ is nil, system will query user
before trimming versions. Otherwise it does it silently.
If ‘vc-make-backup-files’ is nil, which is the default,
no backup files are made for files managed by version control.
(This is because the version control system itself records previous versions.)
See the subroutine ‘basic-save-buffer’ for more information.
(fn &optional ARG)
exports with blog and shows the result in an xwidget to the
right; for a multiple-article file, just the heading at point. It works on any Org file, blog or not
—say, ~/holy-books/holy-books.org— by way of M-x my/blogging-mode; outside ~/blog/, we skip
the blog-wide steps, refreshing the posts registry and validating slugs, so that a slug clash in the
blog never blocks saving another repo's file.
4.3.1. Previews: export, then show in an xwidget
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 by C-x C-s under `my/blogging-mode'.
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)))))
(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 by C-x C-s under `my/blogging-mode'.
Opens (or reuses) an xwidget window to the right of the Org source buffer,
maintaining a stable [Org source | xwidget] side-by-side layout."
(let ((kill-buffer-query-functions nil))
(unless (blog--multiple-style-p)
(user-error "Not a multiple-style file"))
;; 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-sC-x C-s ∷ save-buffer
Save current buffer in visited file if modified.
Variations are described below.
By default, makes the previous version into a backup file
if previously requested or if this is the first save.
Prefixed with one C-u, marks this version
to become a backup when the next save is done.
Prefixed with two C-u’s,
makes the previous version into a backup file.
Prefixed with three C-u’s, marks this version
to become a backup when the next save is done,
and makes the previous version into a backup file.
With a numeric prefix argument of 0, never make the previous version
into a backup file.
Note that the various variables that control backups, such
as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’,
and ‘backup-inhibited’, to name just the more popular ones, still
control whether a backup will actually be produced, even when you
invoke this command prefixed with two or three C-u’s.
If a file’s name is FOO, the names of its numbered backup versions are
FOO.~i~ for various integers i. A non-numbered backup file is called FOO~.
Numeric backups (rather than FOO~) will be made if value of
‘version-control’ is not the atom ‘never’ and either there are already
numeric versions of the file being backed up, or ‘version-control’ is
non-nil.
We don’t want excessive versions piling up, so there are variables
‘kept-old-versions’, which tells Emacs how many oldest versions to keep,
and ‘kept-new-versions’, which tells how many newest versions to keep.
Defaults are 2 old versions and 2 new.
‘dired-kept-versions’ controls Dired’s clean-directory (.) command.
If ‘delete-old-versions’ is nil, system will query user
before trimming versions. Otherwise it does it silently.
If ‘vc-make-backup-files’ is nil, which is the default,
no backup files are made for files managed by version control.
(This is because the version control system itself records previous versions.)
See the subroutine ‘basic-save-buffer’ for more information.
(fn &optional ARG)
, a standalone foo.org exports beside itself as foo.html, and a container heading to
public/<slug>.html. Narrowing, C-x n sC-x n s ∷ org-narrow-to-subtree
Narrow buffer to the current subtree.
Use the command ‘C-x n w’ to see the whole buffer again.
With optional argument ELEMENT narrow to subtree around ELEMENT.
(fn &optional ELEMENT)
, keeps this fast.
4.4. 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)))
4.5. 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")))
4.6. 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.
(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-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))))))
5. From Org files to post-alists
Every page the blog generates —article, index card, tag page, RSS item— is driven by a post-alist: one article's metadata, as an alist from keys to strings. By way of example,
(("file" . "HeytingAlgebra") ("title" . "…") ("date" . "2026-05-04") ("tags" . "math lisp")
("image" . "…") ("description" . "…") ("url" . "https://alhassy.com/HeytingAlgebra") …)
blog--info reads one from a standalone file's #+keyword: lines, blog--info-multiple reads one per
container heading, and blog--refresh-posts gathers them all into blog-posts, newest first.
5.1. 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))))))))
5.2. blog–info: Core helper to get the plist/JSON metadata about each post
Details
(defun blog--draft-file-p (file)
"FILE ↦ non-nil when it lives under ~/blog/draft/, whose every article is a draft."
(file-in-directory-p file (expand-file-name "draft" blog-posts-directory)))
(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)))
;; A draft says #+draft: t, or lives under draft/; the temp file of a container draft says the former.
(cons "draft" (when (or (blog--draft-file-p post-filename)
(progn (goto-char (point-min))
(re-search-forward "^#\\+draft:[ ]*t[ ]*$" nil t)))
"t"))
;; 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)))))))))
5.3. 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 @draft? (json)
"JSON ↦ non-nil when it's a draft: published at its URL, yet unlisted; see §“Drafts”."
(equal "t" (map-elt json "draft")))
(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 '<br>' 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"))
5.3.1. @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://alhassy.com/resources/badge-history.svg\"></a>"))
5.3.3. @image
Details
Every article has an image ^_^ —a resources/ filename, a path, or a URL; emacs-birthday-present.png otherwise.
Credits live in one place: resources/credits.org holds a table, one row per file, that @image and
image: consult by filename whenever a spec carries no explicit credit.
(defvar blog--credits-cache nil
"(MTIME . ROWS) of resources/credits.org, so we parse the table only when it changes.")
(defun blog--credit (file)
"FILE ↦ its Org-formatted credit line from resources/credits.org, or nil when uncredited."
(let ((credits (expand-file-name "resources/credits.org" blog-posts-directory)))
(when (file-exists-p credits)
(let ((mtime (file-attribute-modification-time (file-attributes credits))))
(unless (equal mtime (car blog--credits-cache))
(setq blog--credits-cache
(cons mtime (with-temp-buffer
(insert-file-contents credits)
(re-search-forward org-table-line-regexp)
(org-table-to-lisp))))))
(-when-let ((_ title creator source licence licence-url)
(assoc (file-name-nondirectory file) (cdr blog--credits-cache)))
(format "Image credit: [[%s][“%s”]] by %s, %s." source title creator
(if (s-blank? licence-url) licence (format "[[%s][%s]]" licence-url licence)))))))
(defun blog--org-links-to-html (s)
"S ↦ S with every Org link [[url][text]] rendered as an <a> anchor."
(replace-regexp-in-string "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" "<a href=\"\\1\">\\2</a>" s))
(defun blog--org-links-to-plain (s)
"S ↦ S with every Org link [[url][text]] reduced to its text; e.g., for a tooltip."
(replace-regexp-in-string "\\[\\[[^]]+\\]\\[\\([^]]+\\)\\]\\]" "\\1" s))
(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 3 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 Photo by [[https://example.com][Dora]], CC BY 2.0.
#+fileimage: https://www.youtube.com/watch?v=BQdNhtJSbqk
+ Notice that the third indicates explicit width and height.
+ The fourth, a YouTube video, shows the video's thumbnail, which links to the video.
+ It also ends with an optional credit: all text after the height (and after an optional =no-border=),
rendered as a caption beneath the image, with Org links =[[url][text]]= becoming anchors.
Without one, the image's row in resources/credits.org, if any, is the caption.
+ (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.)
"
(-let* (((image width height . rest) (s-split " " (map-elt json "image")))
;; A YouTube URL shows the video's 4:3 thumbnail, which links to the video.
(video-id (and (string-match "youtu\\(?:\\.be/\\|be\\.com/watch\\?v=\\)\\([-_[:alnum:]]+\\)" image)
(match-string 1 image)))
(href (and video-id image))
(image (if video-id (format "https://img.youtube.com/vi/%s/hqdefault.jpg" video-id) image))
(height (or height (and video-id "263")))
(no-border? (equal (car rest) "no-border"))
(credit (or (s-presence (s-join " " (if no-border? (cdr rest) rest)))
(blog--credit 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))))
(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>%s</center>"
(or href image)
(cond (video-id "🍿 Click me to see the video; or click the title to read the article 📚")
((s-blank? credit) (concat "Image credit " image))
(t (blog--org-links-to-plain credit)))
image no-border? width height
(if (s-blank? credit) ""
(format "<br><small class=\"image-credit\">%s</small>" (blog--org-links-to-html credit)))))))
5.4. blog–article-style, blog–make-slug: Utilities for the multiple article style
Details
blog--article-style reads #+article_style:; C-x C-sC-x C-s ∷ save-buffer
Save current buffer in visited file if modified.
Variations are described below.
By default, makes the previous version into a backup file
if previously requested or if this is the first save.
Prefixed with one C-u, marks this version
to become a backup when the next save is done.
Prefixed with two C-u’s,
makes the previous version into a backup file.
Prefixed with three C-u’s, marks this version
to become a backup when the next save is done,
and makes the previous version into a backup file.
With a numeric prefix argument of 0, never make the previous version
into a backup file.
Note that the various variables that control backups, such
as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’,
and ‘backup-inhibited’, to name just the more popular ones, still
control whether a backup will actually be produced, even when you
invoke this command prefixed with two or three C-u’s.
If a file’s name is FOO, the names of its numbered backup versions are
FOO.~i~ for various integers i. A non-numbered backup file is called FOO~.
Numeric backups (rather than FOO~) will be made if value of
‘version-control’ is not the atom ‘never’ and either there are already
numeric versions of the file being backed up, or ‘version-control’ is
non-nil.
We don’t want excessive versions piling up, so there are variables
‘kept-old-versions’, which tells Emacs how many oldest versions to keep,
and ‘kept-new-versions’, which tells how many newest versions to keep.
Defaults are 2 old versions and 2 new.
‘dired-kept-versions’ controls Dired’s clean-directory (.) command.
If ‘delete-old-versions’ is nil, system will query user
before trimming versions. Otherwise it does it silently.
If ‘vc-make-backup-files’ is nil, which is the default,
no backup files are made for files managed by version control.
(This is because the version control system itself records previous versions.)
See the subroutine ‘basic-save-buffer’ for more information.
(fn &optional ARG)
, blog-publish-all, and
blog-new-post dispatch on it. A slug names both <slug>.html and alhassy.com/<slug>;
see §slugs.
(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 "^-\\|-$" "")))
(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))
5.5. 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: or :ignore: tag, the COMMENT keyword,
or is the footnote section: none of these is an article.
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)
(member "ignore" heading-tags)
(org-element-property :commentedp headline-node)
(org-element-property :footnote-section-p headline-node))
(let* (;; draft?
(draft? (or (member "draft" heading-tags)
(equal "t" (org-element-property :DRAFT headline-node))
(blog--draft-file-p container-file)))
;; 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; then a :REDIRECT: source's own #+fileimage:; then tags
(image (or (org-element-property :IMAGE headline-node)
(and (org-element-property :REDIRECT headline-node)
(blog--redirect-keyword slug "fileimage"))
(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))
(redirect (org-element-property :REDIRECT headline-node))
;; A redirected article's history is its source's, when that's on GitHub.
(history (or (and redirect (blog--redirect-history-url redirect))
(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" redirect)
(cons "load" (org-element-property :LOAD 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);
;; a :REDIRECT: to a container becomes that container's articles.
(mapcan #'blog--redirected-container-infos
(delq nil
(cl-mapcar (lambda (h slug) (blog--info-subtree h container-file slug))
top-headings slugs))))))
(defun blog--redirected-container-infos (info)
"INFO ↦ (INFO), or the infos of its vendored :REDIRECT: source's articles when that's a container.
Each inherits INFO's redirect, load, history, draft, and tags, and remembers INFO's slug as
\"redirect-slug\", which names the vendored file; see §“A REDIRECT to a container”."
(let ((vendored (and (map-elt info "redirect")
(expand-file-name (concat blog-redirects-subdir (@slug info) ".org") blog-posts-directory))))
(if (not (and vendored (file-exists-p vendored) (blog--multiple-style-p vendored)))
(list info)
(mapcar (lambda (article)
(append (list (cons "redirect-slug" (@slug info))
(cons "tags" (s-join " " (seq-uniq (s-split " " (concat (map-elt info "tags") " " (map-elt article "tags")) t)))))
(map-filter (lambda (key _) (member key '("redirect" "load" "history" "draft"))) info)
article))
(blog--info-multiple vendored)))))
5.7. 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)))))
6. Exporting an article: the blog backend
The pieces that turn an Org file into a blog page:
my/blogging-mode---C-x C-sC-x C-s ∷ save-buffer Save current buffer in visited file if modified. Variations are described below. By default, makes the previous version into a backup file if previously requested or if this is the first save. Prefixed with one C-u, marks this version to become a backup when the next save is done. Prefixed with two C-u’s, makes the previous version into a backup file. Prefixed with three C-u’s, marks this version to become a backup when the next save is done, and makes the previous version into a backup file. With a numeric prefix argument of 0, never make the previous version into a backup file. Note that the various variables that control backups, such as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’, and ‘backup-inhibited’, to name just the more popular ones, still control whether a backup will actually be produced, even when you invoke this command prefixed with two or three C-u’s. If a file’s name is FOO, the names of its numbered backup versions are FOO.~i~ for various integers i. A non-numbered backup file is called FOO~. Numeric backups (rather than FOO~) will be made if value of ‘version-control’ is not the atom ‘never’ and either there are already numeric versions of the file being backed up, or ‘version-control’ is non-nil. We don’t want excessive versions piling up, so there are variables ‘kept-old-versions’, which tells Emacs how many oldest versions to keep, and ‘kept-new-versions’, which tells how many newest versions to keep. Defaults are 2 old versions and 2 new. ‘dired-kept-versions’ controls Dired’s clean-directory (.) command. If ‘delete-old-versions’ is nil, system will query user before trimming versions. Otherwise it does it silently. If ‘vc-make-backup-files’ is nil, which is the default, no backup files are made for files managed by version control. (This is because the version control system itself records previous versions.) See the subroutine ‘basic-save-buffer’ for more information. (fn &optional ARG)
previews any Org file, blog or not, with the blog's CSS/JS stack.- The
blogexport backend —wraps each article in header, tags, image, badges, and footer. - 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.
6.1. blog–header-html: the site nav, styles, and title atop every article
Details
Every article opens with the site nav, our CSS/JS stack, and a fancy title; blog--inner-template emits
them. To style any Org file this way, blog or not, just M-x my/blogging-mode —there's no
per-file incantation to remember. Outside ~/blog/, CSS and JS load from alhassy.com/resources/
—so we need internet— and the History badge dangles.
(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)))))
6.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.
Broken links never ship. We set org-export-with-broken-links to nil, so a broken link aborts the
export, previews and CI alike; org-special-block-extras consults it too, so a doc: link to nothing
errors rather than shipping an empty tooltip. Two deliberate exceptions remain:
- A narrowed preview, C-x n s
C-x n s ∷ org-narrow-to-subtree Narrow buffer to the current subtree. Use the command ‘C-x n w’ to see the whole buffer again. With optional argument ELEMENT narrow to subtree around ELEMENT. (fn &optional ELEMENT)
, whose links to the rest of the file dangle by design;blog--style-setuptolerates them there, buffer-locally in the export's own copy. - An article declaring
#+options: broken-links:t; Org honours it, and none of ours does.
That global aside, 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)))
;; Never export a broken link; e.g., a `doc:' link to nothing errors rather than shipping an empty tooltip.
(setq org-export-with-broken-links nil)
;; 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))
:filters-alist '((:filter-options . blog--draft-noindex))
:translate-alist '((headline . blog--headline)
(link . blog--link)
(inner-template . blog--inner-template)))
(defun blog--draft-noindex (info _backend)
"INFO ↦ INFO, with a noindex robots tag in a draft's <head>, so search engines skip it."
(when (@draft? (blog--info (plist-get info :input-file)))
(plist-put info :html-head-extra
(concat (plist-get info :html-head-extra) "\n<meta name=\"robots\" content=\"noindex\">")))
info)
(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)
(when (@draft? post)
(concat "<center><p style=\"background:#fff3cd;border:1px dashed #b58900;padding:0.5em;"
"max-width:40em;\">🚧 <b>Draft</b> —unlisted while I think it over; "
"please don't share it just yet.</p></center>"))
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)
"Tag the “* Abstract” heading :abstract:, for =blog= exports only; e.g., =* Abstract :ignore:= ↦ =* Abstract :ignore:abstract:=.
The =abstract= block's tag then boxes the section's text, and =:ignore:= axes the heading.
(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) (setq-local org-export-with-broken-links t))
(when (re-search-forward "^\\* Abstract\\b.*?[ \t]*$" nil t)
(skip-chars-backward " \t")
;; Join an existing tag cluster, such as :ignore:, else start one.
(insert (if (looking-back "[ \t]:\\(?:[^ \t\n]+:\\)+" (line-beginning-position)) "abstract:" " :abstract:")))))
(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)
6.4. Colourised source: one consistent theme
A colourised .org.html page is only as colourful as the faces Emacs has when it is made. CI runs
Emacs in batch, with no theme, so we load tsdh-light —and org-modern, when available— to give
every source view, and every exported #+begin_src block, the same look we get in-buffer.
;; Load a colour theme so htmlize has real colours (not just bold/italic)
;; to bake into the `#+begin_src' blocks of every exported article, and
;; into the per-article `.org.html' colourised-source views.
;;
;; In batch mode no theme is active by default ---font-lock faces then
;; carry only weight/slant attributes, which is why Elisp blocks looked
;; monochrome on deployed AlBasmala.html. Most built-in themes
;; (`tango', `leuven', `wombat', …) leave `font-lock-keyword-face' et al
;; with `:foreground unspecified', so loading them is a no-op for
;; htmlize. `tsdh-light' is the exception among shipped themes: it
;; assigns explicit colours to the full font-lock face family ---which is
;; exactly what we want baked into the HTML via `inline-css'.
(when noninteractive
(load-theme 'tsdh-light t)
;; org-special-block-extras stores documentation strings as tooltips
;; using `font-lock-comment-face'; make sure the theme has taken effect
;; before any export buffer is created.
(global-font-lock-mode 1)
;; Fontify headings, TODO/DONE keywords, tags, priorities the org-modern
;; way ---htmlize's `inline-css' mode bakes those face colours into every
;; .org.html page, so the colourised-source view inherits the same look
;; we get in-buffer. Fancy glyph substitutions (prettified bullets) are
;; `display' overlays and don't survive htmlize ---that's fine; the
;; colour wins carry most of the aesthetic.
(when (require 'org-modern nil t)
(global-org-modern-mode 1)))
6.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, set up above, baked into the spans.
;; htmlize is chatty; hush it.
(advice-add 'htmlize-buffer-1 :around
(lambda (orig &rest args) (let ((inhibit-message t)) (apply orig args))))
;; Faces like (:background white) name colours by symbol, which htmlize's string-match chokes on.
(advice-add 'htmlize-color-to-rgb :filter-args
(lambda (args) (cons (if (and (car args) (symbolp (car args))) (symbol-name (car args)) (car args)) (cdr args))))
(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://alhassy.com/resources/badge-source.svg\"></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")))
6.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.
7. Look and Feel
What every page's <head> loads:
7.1. Style! ✨ What do we want to be inserted into the head of every page?
7.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\" />"
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 §7.2.
- blog-banner.css
- Finally, we want a beautiful welcome mat, see §7.1.3.
7.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, §7.1.3, and support for using
LaTeX-style notation to write mathematics, §7.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://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>"
))
7.1.4. Miscellaneous Styles
Smaller touches follow: curvy source blocks, pink inline code and tables, and greenish folded regions.
7.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
.srcandpre.src:beforeare 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: /* Iosevka Slab (SIL OFL), self-hosted and subset to Latin, Greek, arrows & maths; à la ogbe.net */
20: @font-face { font-family: "Iosevka Slab Web"; font-display: swap; font-weight: normal; font-style: normal;
21: src: url('fonts/iosevka-slab-regular.woff2') format('woff2') }
22: @font-face { font-family: "Iosevka Slab Web"; font-display: swap; font-weight: bold; font-style: normal;
23: src: url('fonts/iosevka-slab-bold.woff2') format('woff2') }
24: @font-face { font-family: "Iosevka Slab Web"; font-display: swap; font-weight: normal; font-style: italic;
25: src: url('fonts/iosevka-slab-italic.woff2') format('woff2') }
26:
27: pre, code { font-family: "Iosevka Slab Web", monospace !important; }
28:
29: /* Inline code with ~ ⋯ ~ and = ⋯ = sits in its paragraph: the slab face on a faint blush ---the old pink, whispering */
30: code {
31: background: rgba(255, 192, 203, 0.3) !important;
32: border-radius: 3px;
33: padding: 0 0.2em;
34: font-size: 0.95em;
35: }
Code such as (= 2 (+ 1 1)) now reads as part of its sentence: a slab-serif monospace face on a faint
blush, enough to mark it as code without shouting over the prose.
7.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;
}
| 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.
7.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;
}
7.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?
- How does Org's HTML export TOCs? ⇒ org-html-toc
Build a table of contents. DEPTH is an integer specifying the depth of the table. INFO is a plist used as a communication channel. Optional argument SCOPE is an element defining the scope of the table. Return the table of contents as a string, or nil if it is empty. (fn DEPTH INFO &optional SCOPE)
- Looking at its source, it emits
<h2>Table of Contents</h2>—the phrase coming from the internal org-html--translateTranslate string S according to specified language. INFO is a plist used as a communication channel. (fn S INFO)
. - The
blogbackend'sinner-templateswaps that phrase for "Ξ" —in our output alone. - Joy ♥‿♥
7.3. Clickable Sections with Sensible Anchors
7.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.
Two headings named, say, Notes would share the slug notes; plain C-x C-sC-x C-s ∷ save-buffer
Save current buffer in visited file if modified.
Variations are described below.
By default, makes the previous version into a backup file
if previously requested or if this is the first save.
Prefixed with one C-u, marks this version
to become a backup when the next save is done.
Prefixed with two C-u’s,
makes the previous version into a backup file.
Prefixed with three C-u’s, marks this version
to become a backup when the next save is done,
and makes the previous version into a backup file.
With a numeric prefix argument of 0, never make the previous version
into a backup file.
Note that the various variables that control backups, such
as ‘version-control’, ‘backup-enable-predicate’, ‘vc-make-backup-files’,
and ‘backup-inhibited’, to name just the more popular ones, still
control whether a backup will actually be produced, even when you
invoke this command prefixed with two or three C-u’s.
If a file’s name is FOO, the names of its numbered backup versions are
FOO.~i~ for various integers i. A non-numbered backup file is called FOO~.
Numeric backups (rather than FOO~) will be made if value of
‘version-control’ is not the atom ‘never’ and either there are already
numeric versions of the file being backed up, or ‘version-control’ is
non-nil.
We don’t want excessive versions piling up, so there are variables
‘kept-old-versions’, which tells Emacs how many oldest versions to keep,
and ‘kept-new-versions’, which tells how many newest versions to keep.
Defaults are 2 old versions and 2 new.
‘dired-kept-versions’ controls Dired’s clean-directory (.) command.
If ‘delete-old-versions’ is nil, system will query user
before trimming versions. Otherwise it does it silently.
If ‘vc-make-backup-files’ is nil, which is the default,
no backup files are made for files managed by version control.
(This is because the version control system itself records previous versions.)
See the subroutine ‘basic-save-buffer’ for more information.
(fn &optional ARG)
refuses, while
C-u C-x C-s stamps notes--placeholder-2, … so we can preview a rough draft right away. Since
blog--make-slug collapses dash runs, no real slug contains --, so the marker is unambiguous:
every later save logs each placeholder to *Messages* ---C-h e--- until we name it properly.
blog--ensure-useful-section-anchors: Run on every C-x C-s
(defconst blog--placeholder-id-regexp "--placeholder-[0-9]+\\'"
"Matches a placeholder CUSTOM_ID; `blog--make-slug' collapses dash runs, so no real slug contains “--”.")
(defun blog--ensure-useful-section-anchors (&optional placeholders)
"Org sections without a CUSTOM_ID are given one derived from the heading title; existing ones are kebab-cased.
Uses `blog--make-slug' so the result is lowercase kebab-case, consistent with
URL slugs. On a collision, pops a message-box and undoes ---unless PLACEHOLDERS
(C-u C-x C-s), whence the id becomes a unique placeholder to tidy up in due time.
E.g., ↯ We'll go on a ∀∃⇅ adventure
↦ well-go-on-a-adventure
↦ well-go-on-a-adventure--placeholder-2, when repeated with PLACEHOLDERS"
(interactive "P")
;; Hand-written ids become lower-case kebab-case too, e.g., Next_Steps ↦ next-steps, as do the [[#id]] links naming them.
(org-map-entries
(lambda ()
(let* ((old (org-entry-get nil "CUSTOM_ID"))
(new (blog--make-slug (string-replace "_" " " old))))
(unless (equal old new)
(org-entry-put nil "CUSTOM_ID" new)
(save-excursion
(goto-char (point-min))
(while (search-forward (format "[[#%s]" old) nil t)
(replace-match (format "[[#%s]" new) t t))))))
"CUSTOM_ID={.}")
;; Existing ids count too, else a fresh slug could silently clash with a hand-written one.
(let ((ids (org-map-entries (lambda () (org-entry-get nil "CUSTOM_ID")) "CUSTOM_ID={.}")))
(org-map-entries
(lambda ()
(org-with-point-at (point)
(unless (org-entry-get nil "CUSTOM_ID")
(let* ((slug (blog--make-slug (nth 4 (org-heading-components))))
(id slug)
(n 1))
(when (member id ids)
(unless placeholders
(message-box "Oh no, a repeated id!\n\n\t%s\n\nC-u C-x C-s makes every repeated id unique, e.g., %s--placeholder-2, so we can preview now and name them in due time."
id id)
(undo)
(setq quit-flag t))
(while (member id ids)
(setq id (format "%s--placeholder-%d" slug (cl-incf n)))))
(push id ids)
(org-entry-put nil "CUSTOM_ID" id))))))))
(defun blog--warn-placeholder-ids ()
"Log each placeholder CUSTOM_ID, with its heading, to *Messages*; then say how many there are."
(when-let ((hits (org-map-entries
(lambda () (format " %s ← %s" (org-entry-get nil "CUSTOM_ID") (org-get-heading t t t t)))
"CUSTOM_ID={--placeholder-[0-9]+$}")))
(dolist (h hits) (message "%s" h))
(message "⚠ %d placeholder id(s) in this article; C-h e to see them all" (length hits))))
;; 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.
7.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.
7.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.
\[ 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!
7.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.
\[ p ⊑ q \quad ≡ \quad p ⊓ q = p \tag{⊑-Definition}\label{⊑-Definition} \]
\[ p ⊑ q \quad ≡ \quad p ⊔ q = q \tag{⊑-Definition}\label{Order-Definition} \]
7.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; }
7.4.3. Making Math Stick-out with Spacing!
Notice how the math expressions stick out in these following sentences:
- We use \(x\) as the name of the unknown.
- The phrase \(∀ x • ∃ y • x 〔R〕 y\) indicates that relation \(R\) is "total".
Nice, the following pads MathJax elements so that they stand out.
7.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.
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.
9. Publishing
The build pipeline, which CI runs on every push to master.
9.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 §9.2.
blog-publish-all dispatches on #+article_style::
- standalone (the default) —one
.orgfile ↦ one HTML file. - multiple ---
blog--publish-multiple-articlescallsblog--publish-single-subtreeon 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.
9.1.1. 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:
- #+modified: ---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
is stamped with #+modified: <today>, below its leading #-lines, and
published ---a stray scratch file becomes a fresh article, not a
failed build."
(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 (let ((today (format-time-string "%Y-%m-%d")))
;; Below the keyword block, so a leading “-*- … -*-” line stays first.
(goto-char (point-min))
(while (looking-at "#") (forward-line))
(insert (format "#+modified: %s\n" today))
(write-region nil nil file)
(when-let ((buf (get-file-buffer file)))
(with-current-buffer buf
(unless (buffer-modified-p) (revert-buffer t t t))))
(message "%s had no #+modified:; stamped it %s" (f-filename file) today)
t))))))
(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)
;; Vendor every :REDIRECT: first, so the posts registry reads each source's metadata ---say, its image--- fresh.
(dolist (f (blog--org-files))
(when (equal (blog--file-keyword f "article_style") "multiple")
(dolist (info (blog--info-multiple f))
(when-let ((redirect (map-elt info "redirect")))
(blog--vendor-one-redirect (or (map-elt info "redirect-slug") (map-elt info "slug")) redirect)))))
(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--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)))
9.1.2. 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:
blog--info-subtreereads the heading's metadata.- 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. - The subtree body is pasted in and promoted a level; e.g.,
** Abstract :ignore:↦* Abstract :ignore:, just whereblog--style-setuplooks for it. - We export with the
blogbackend, as for any standalone post. - 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.
(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)))
(redirect-slug (or (cdr (assoc "redirect-slug" info)) slug)))
(if redirect
(with-current-buffer tmp-buf
(goto-char (point-max))
;; Vendor afresh, so the #+include: resolves on this machine and on
;; CI, and tracks the source's tip; the banner sits atop the article.
;; A package's write-up uses the package's own link types; load it first,
;; unless it's already loaded ---say, our working copy, mid-hack.
(when-let ((lib (cdr (assoc "load" info))))
(unless (featurep (intern (file-name-base lib)))
(load (blog--redirect-load-file redirect-slug redirect lib) nil t)))
;; An article of a redirected container is its heading's contents, read with the
;; container's file-level keywords ---say, its #+macro:s and header-args.
(let ((vendored (expand-file-name (blog--vendor-one-redirect redirect-slug redirect) blog-posts-directory)))
(insert (blog--redirect-banner redirect)
(if (assoc "redirect-slug" info)
(format "#+setupfile: %s\n#+include: \"%s::#%s\" :only-contents t :minlevel 1\n"
vendored vendored slug)
(format "#+include: \"%s\"\n" vendored))))
(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: the vendored file for a redirect, else the subtree.
(if (cdr (assoc "redirect" info))
(blog--htmlize-file (expand-file-name (concat blog-redirects-subdir (or (cdr (assoc "redirect-slug" info)) slug) ".org")
blog-posts-directory))
(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))
;; A heading's infos are usually just its own; a :REDIRECT: to a container yields its articles.
(by-slug (let ((h (make-hash-table :test #'equal)))
(dolist (i (reverse all-infos)) (push i (gethash (or (map-elt i "redirect-slug") (@slug 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)))
(dolist (info (gethash slug by-slug))
(message "=> Publishing subtree: %s (%s)..." (@title info) (@slug info))
(blog--publish-single-subtree (point) container-file info)
(push (@slug info) results))))
(org-end-of-subtree t t))))
(nreverse results)))
9.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/ ...
masterholds only sources:.orgfiles,resources/, and config.- Every push makes CI export every post,
index.html, thetag-*.htmlpages, andrss.xmlintopublic/, alongside a copy ofresources/. peaceiris/actions-gh-pagesforce-pushespublic/togh-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-pagesbranch; it is exactly whatalhassy.comserves. - One-time setup: in the repo's Settings → Pages, set Source to "Deploy
from a branch", with branch
gh-pagesand folder/. Otherwise GitHub Pages servesmaster, 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.
9.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)))))))
9.2.2. 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
- installs our packages and upgrades them to MELPA's latest —the restored cache only spares us re-downloading what hasn't changed—, then saves them as a fresh cache;
- tangles this file and loads
AlBasmala.el; - runs
blog-publish-all; and - on pushes to
master, and on the daily schedule, force-pushespublic/togh-pages.
The daily run is for :REDIRECT: articles: they track their repos' tips, but pushes there
don't trigger us; see §staleness.
A rebuild with nothing new costs about a minute of Actions time, which is free for a public repo.
name: blog CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
workflow_dispatch:
# :REDIRECT: articles track their repos' tips, which don't trigger us; so rebuild daily too.
# Cron fields are "minute hour day-of-month month day-of-week", so "17 6 * * *" ≡ every day at 06:17 UTC
# ---best-effort: GitHub starts scheduled runs late under load, often by hours.
schedule:
- cron: "17 6 * * *"
# 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
# org-special-block-extras' graph block, in its manual, draws with Graphviz.
- name: Install Graphviz
run: sudo apt-get install -y graphviz
# … and, under :smartdiagram, typesets with pdflatex, then pdftoppm (poppler) makes the PNG.
# tlmgr resolves no dependencies, so we list what a smartdiagram's standalone document loads;
# cached, a minimal TeX Live costs seconds rather than texlive-full's gigabytes.
- name: Install a minimal TeX Live, for smartdiagrams
uses: TeX-Live/setup-texlive-action@v4
with:
packages: |
scheme-basic
standalone
preview
smartdiagram
pgf
xkeyval
xstring
etoolbox
varwidth
xcolor
- name: Install poppler, for pdftoppm
run: sudo apt-get install -y poppler-utils
# A key unique to each run never hits exactly, so the newest elpa-…-* cache is restored
# (restore-keys) and, after the upgrade below, saved anew. A fixed key would hit forever and
# never be re-saved, freezing our packages at whatever MELPA had when the key last changed.
- name: Cache Emacs packages
uses: actions/cache@v4
with:
path: ~/.emacs.d/elpa
key: elpa-${{ runner.os }}-emacs30.1-${{ github.run_id }}
restore-keys: elpa-${{ runner.os }}-emacs30.1-
- name: Install Emacs package dependencies
run: |
emacs --batch \
--eval "(require 'package)" \
--eval "(setq package-archives '((\"gnu\" . \"https://elpa.gnu.org/packages/\") (\"melpa\" . \"https://melpa.org/packages/\")) package-install-upgrade-built-in t)" \
--eval "(package-initialize)" \
--eval "(package-refresh-contents)" \
--eval "(dolist (p '(dash f s htmlize org-special-block-extras)) (unless (package-installed-p p) (package-install p)))" \
--eval "(package-upgrade-all)"
- 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 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' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: gh-pages
cname: alhassy.com
10. Dependencies
Every block in this file leans on a handful of packages, so AlBasmala.el loads them first: the
stub atop this file tangles the block below, via the noweb reference <<albasmala-dependencies>>, right after
the lexical-binding cookie.
dash,f,s—list, file, and string utilities.htmlize—colourises Org source for each article's Source badge; §one consistent theme.org-special-block-extras---org-defblock,org-deflink, andorg-deftag. We insist on MELPA's copy, over any local development version, and on one recent enough to haveorg-deftag.xml,lf,ox-extra—escaping RSS text,lf-assertchecks, and the:ignore:tag that axes a heading yet keeps its text.
(use-package dash :ensure t :demand t)
(use-package f :ensure t :demand t)
(use-package s :ensure t :demand t)
(use-package htmlize :ensure t :demand t)
;; Force the MELPA-installed copy; init.el may have loaded a local dev
;; version with a different (buggy) API.
(ignore-errors (unload-feature 'org-special-block-extras t))
(when-let* ((elpa-ospe (car (last (directory-files ;; Newest: MELPA versions are dates, so they sort.
package-user-dir
t "^org-special-block-extras-[0-9]")))))
(load (expand-file-name "org-special-block-extras" elpa-ospe) nil :nomessage))
(use-package org-special-block-extras :ensure t :demand t)
;; An ospe with `org-deftag' also renders its blocks for our derived `blog' backend.
(unless (fboundp 'org-deftag)
(user-error "AlBasmala needs an org-special-block-extras with org-deftag; M-x package-upgrade it"))
(require 'xml) ;; xml-escape-string for blog--make-one-rss-feed
(require 'lf) ;; lf-assert; already a dependency of org-special-block-extras
(require 'ox-extra)
(ox-extras-activate '(ignore-headlines))
11. Appendices
Appendix 𝐈 Using a Custom Domain: alhassy.com
Details
- Go to your repo: https://github.com/alhassy/alhassy.github.io/settings/pages
- Add a
Custom Domainsuch as an "apex domain" likealhassy.com(or a www domain likewww.alhassy.com)- Apex domains require
Arecords to be setup on your DNS provider. - www domains require
CNAMErecords. - On my DNS provider, I setup both: That way, with or without
www., people will arrive at my blog.
- Apex domains require
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 - In a terminal, run:
dig www.alhassy.com +nostats +nocomments +nocmd- The
www.is intentional.
- The
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.comtoalhassy.github.ioto be completed. It may take longer; keep reading.In your DNS provider, add 4 records, one for each IP Address you got from the
digcommand. (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 - Run
dig alhassy.com +noall +answer -t Aand ensure it points to these IP addresses.- Notice the lack of a
www.
- Notice the lack of a
Open an incognito browser, private browsing session, and navigate to
alhassy.com. a. If this redirects to youralhassy.github.ioblog, 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:
11.1. Appendix 𝐈𝐈 Stuff that really belongs in org-special-block-extras
11.1.1. Image Org Link
imagelink
A quick way to embed clickable images, along with tooltip credits and other configs.
(use-package org-special-block-extras :ensure t :demand t
:config (org-special-block-extras-mode t))
(org-deflink image (label)
"Provide a quick way to insert images along with credits via tooltips.
Example usage:
image:https://upload.wikimedia.org/wikipedia/commons/3/33/Heisokudachi.svg|100|100
image:URL|WIDTH|HEIGHT|CENTER?|CREDIT?
"
(-let [(image width height center? credit?) (s-split "|" label)]
(let ((it (format "<a href=\"%s\" class=\"tooltip\" title=\"%s\"><img src=\"%s\" alt=\"Article image\"
width=\"%s\" height=\"%s\" align=\"top\"/></a>"
image
(if credit? (concat "Image credit " credit?)
(or (-some-> (blog--credit image) blog--org-links-to-plain) (concat "Image credit " image)))
image width height)))
(if center?
(format "<center> %s </center>" it)
it))))
11.2. Appendix 𝐈𝐈𝐈 Speculative TODO-s
Notes, not code: nothing below tangles.
- Support :HTMLEXPORTMETHOD: property for trees, eg if i have an article I want exported via ox-jeopardy, then I can I can use that?
- 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!?
- Add a link to https://alhassy.com/next-700-module-systems/proposal/thesis-proposal.pdf and to the actual thesis.
- Why not move the Org sources into a
srcdirectory, besideresourcesandpublic? - 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.
- 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.
- Super simple validation: The number of blog posts can only increase.
- 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!
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 ლ(ಠ益ಠ)ლ
]wrt floating toc:
[Strange:
If I zoom in over 100% in my browser, the toc disappears until I zoom out.
]- TODO: Make the floating toc "Ξ" be aware of links…
… so that "blog-new-article
Make a new article for my blog; prompting for the necessary ingredients. If the filename entered already exists, we simply write to it. The user notices this and picks a new name. This sets up a new article based on existing tags and posts. + Use C-SPC to select multiple tag items Under ‘my/blogging-mode’, every save, C-x C-s, live-previews the article.
" renders nicely in the floating doc! - TODO: Find-replace all px with percentages, then ensure things look good!?
Column Width
When blogging, I see live HTML previews whenever I save thanks to 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, kebab-case existing ids (and their [[#id]] links), vendor :REDIRECT: sources, bump #+modified: / :MODIFIED: to today (if present), refresh the posts registry, validate slugs (kebab-case, unique), then save + live preview C-u C-x C-s ---likewise, but repeated section ids become <slug>--placeholder-N; every later save reminds us of them until we rename them 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 This is a minor mode. If called interactively, toggle the ‘My/Blogging mode’ mode. If the prefix argument is positive, enable the mode, and if it is zero or negative, disable the mode. If called from Lisp, toggle the mode if ARG is ‘toggle’. Enable the mode if ARG is nil, omitted, or is a positive number. Disable the mode if ARG is a negative number. To check whether the minor mode is enabled in the current buffer, evaluate the variable ‘my/blogging-mode’. The mode’s hook is called both when the mode is enabled and when it is disabled. Key Binding ------------------------------------------------------------------------------- C-RET #<interpreted-function 6BA> C-x C-s #<interpreted-function 3BF> C-c i i blog-insert-image C-c i s blog-insert-screenshot. As such, I cannot have my lines being too wide. I'll settle for a modest 80 columns —set buffer-locally bymy/blogging-mode. Then M-qM-q ∷ org-fill-paragraph Fill element at point, when applicable. This function only applies to comment blocks, comments, example blocks and paragraphs. Also, as a special case, re-align table when point is at one. For convenience, when point is at a plain list, an item or a footnote definition, try to fill the first paragraph within. If JUSTIFY is non-nil (interactively, with prefix argument), justify as well. If ‘sentence-end-double-space’ is non-nil, then period followed by one space does not end a sentence, so don’t break a line there. The variable ‘fill-column’ controls the width for filling. The REGION argument is non-nil if called interactively; in that case, if Transient Mark mode is enabled and the mark is active, fill each of the elements in the active region, instead of just filling the current element. This function has :before-until advice: ‘org--math-fill-hint’. (fn &optional JUSTIFY REGION)
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.)
- Required loads
Life & Computing Science by Musa Al-hassy is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License