#+title: AlBasmala: @@html: <br>@@ Blogging with Emacs & Org-mode (•̀ᴗ•́)و #+description: How my blog is setup (•̀ᴗ•́)و #+date: <2020-05-03 Sun> #+modified: 2026-10-14 #+filetags: emacs org html css javascript git lisp meta #+fileimage: org_logo.png 150 150 no-border #+site_nav: AlBasmala #+property: header-args :tangle "AlBasmala.el" :results silent :exports code :noeval # Use checkmarks instead of boring bullet points #+html_head: <style> ul {list-style-type: " ✓"} </style> # AlBasmala.el opens with the lexical-binding cookie, then the packages every block relies on; §Dependencies. #+begin_src emacs-lisp :noweb yes :exports none ;;; -*- lexical-binding: t; -*- <<albasmala-dependencies>> #+end_src * Abstract :ignore: :PROPERTIES: :CUSTOM_ID: abstract :END: 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 ---§blog-banner + /Dynamically/ highlighting code from references in prose ---§blog-banner + Tooltips, folded regions, and badges ---badge:org-special-block-extras|2.0|informational|https://alhassy.github.io/org-special-block-extras/|Gnu-Emacs + Overall nice looking HTML style ---org-notes-style + Beautiful math using LaTeX notation, $\forall \phi ⇒ \exists \phi$ ---§MathJax-Support + A floating, yet unobtrusive, table of contents ---§floating-toc # + Headings are clickable links with the resulting anchors being Github-like ---§ensuring-useful-html-anchors and §clickable-headlines + Articles have images and abstracts, shown as cards on the index ---§Images, §Index + Each article links its colourised Org source and its Github history, right under its image ---§footers + Article titles may contain arbitrary ~@@html: ...@@~ /yet/ still render nicely in both the frame tab and page title, thanks to =blog--sanitise-title-js= ---§the header. + A reading-time clock that adjusts as you scroll ---§footers + Style inline code and tables ---§curvy-blocks + Unfurling links ---§unfurling * The name: al-bas-mala :PROPERTIES: :CUSTOM_ID: the-name :END: 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" ;-) * Typical workflow: How do I publish an article? :PROPERTIES: :CUSTOM_ID: typical-workflow-how-do-i-publish-an-article :END: 1. Open an Org-mode buffer ---or call doc:blog-new-article (or doc:blog-new-post). 2. Invoke doc:my/blogging-mode to get live WYSIWYG in an adjacent buffer after every save 〔C-x C-s〕. - The mode is auto-enabled in =~/blog/= via =.dir-locals.el=. 3. Until content: 1. ✏️ Write, write, and write! - E.g., insert an image with 〔C-c i i〕 (from file) or 〔C-c i s〕 (screenshot) 2. 👀 〔C-x C-s〕 to save & preview 🤔 Consider using 〔C-x n s〕, or 〔C-x n n〕, to focus your attention on a particular section, /thereby/ dramatically increasing the speed at which the preview renders. 💥 4. 🤖 ~git push~ when you're done ---CI runs =blog-publish-all= and deploys =public/= to =gh-pages=, index, RSS, and tag pages included. - Changes go live about 2 minutes after the push; see §Deployment via CI. Behind the scenes, a save and a push travel as follows. #+begin_example .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 #+end_example The rest of this article follows one article along that journey: 1. §Anatomy of an article ---what we write. 2. §Writing articles ---=my/blogging-mode=, its live preview, and helpers for new articles and images. 3. §From Org files to post-alists ---how every =.org= file becomes metadata. 4. §Exporting an article ---the =blog= backend: header, badges, footer. 5. §Look and Feel ---the CSS and JS every page loads. 6. §Index, tag pages, and RSS ---the pages built from all articles at once. 7. §Publishing ---=blog-publish-all= and CI's deploy to =gh-pages=. 8. §Dependencies ---what =AlBasmala.el= loads first, and why. ** Why not use an existing blogging platform? :PROPERTIES: :CUSTOM_ID: why-not-use-an-existing-blogging-platform :END: 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. #+include: ~/.emacs.d/init.org::#Mini-tutorial-on-Org-mode * Anatomy of an article :PROPERTIES: :CUSTOM_ID: goal-driven-development :END: <<new-article>> <<initial-setup>> Here's what an example article source looks like: #+begin_example org #+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. #+end_example Almost all ~#+keyword:⋯~ lines are parsed by =blog--info= to build the post-alist used for index/tag-page generation. <<unfurling>> 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. ** The Two Article Styles: =standalone= vs =multiple= :PROPERTIES: :CUSTOM_ID: the-two-article-styles-standalone-vs-multiple :END: We support two ways to author blog posts, selectable per-file via =#+article_style:=. *** =standalone= (the default) :PROPERTIES: :CUSTOM_ID: standalone-the-default :END: One =.org= file = one article; its file-level keywords (=#+title:=, =#+filetags:=, …) supply the metadata. *** =multiple= (container files) :PROPERTIES: :CUSTOM_ID: multiple-container-files :END: 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: #+begin_example org ,#+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: ... #+end_example Key rules: - *Heading tags* → article tags, sans structural ones (=noexport=, =draft=, =ignore=, =details=, …). - *=:draft:=* → a draft, unlisted; =blog-new-post= adds it, and we axe it when ready. See §drafts. - *=:noexport:= or =COMMENT=* → skipped entirely; never exported or indexed. - *=:TITLE:=* → the display title and slug source, overriding a decorated heading. - *=:CUSTOM_ID:=* → the URL slug; see §slugs. - *Abstract* → =:ABSTRACT:=, else the =** Abstract :ignore:= child, else the first paragraph. *** Slug derivation, write-back, and global uniqueness :PROPERTIES: :CUSTOM_ID: slug-derivation-write-back-and-global-uniqueness :END: By way of example, the heading =* Quick Emacs Tip: visual-line-mode= is published at =alhassy.com/quick-emacs-tip-visual-line-mode=. - A container heading's slug is its =:CUSTOM_ID:=; 〔C-x C-s〕 stamps a missing one with =blog--make-slug= of the title, so later title edits never shift the URL; an existing id that isn't lower-case kebab-case ---e.g., a =:REDIRECT:='s =Next_Steps=--- becomes so, =next-steps=, along with every =[[#Next_Steps]]= link to it. - =blog--validate-slugs= refuses a slug that isn't lower-case kebab-case, or that another article ---container or standalone--- already owns, naming both. Each heading is then just another post-alist, so =blog-make-index-page= needs no special case. ** Drafts: published, yet unlisted :PROPERTIES: :CUSTOM_ID: drafts-published-yet-unlisted :END: 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:= ---as =blog-new-post= does--- or carries =:DRAFT: t=; - its standalone file says =#+draft: t=; or - its file lives under =~/blog/draft/=, by way of example =draft/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. ** The <code>#+begin_abstract</code> is an Org-mode Special Block :details_orange: :PROPERTIES: :CUSTOM_ID: the-codebeginabstractcode-is-an-org-mode-special-block :END: An article's =Abstract= section previews it on the index page; see §new-article for a template. The block below adapts the docstring examples of doc:org-defblock. 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. #+begin_src emacs-lisp ;; 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)) #+end_src #+html: <br><center> ★ ★ ★ </center> For example, the source: #+begin_example org ,#+begin_abstract In this article, we learn to have fun! ,#+end_abstract #+end_example Results in: #+begin_abstract In this article, we learn to have fun! #+end_abstract ** REDIRECT: zero-duplication bridges to external Org files :details: :PROPERTIES: :CUSTOM_ID: redirect-zero-duplication-bridges-to-external-org-files :END: 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. #+begin_src org :tangle no ,* 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: #+end_src The first heading is published with the usual synthetic keywords (=#+title:=, =#+date:=, …) followed by #+begin_example #+include: "/…/blog/resources/redirects/my-literate-emacs-config.org" #+end_example =blog--info-subtree= carries the ="redirect"= key along, so the index, tag pages, and RSS treat these articles like any other. *** The repo owns the =.org=; the blog points at it :PROPERTIES: :CUSTOM_ID: the-repo-owns-the-org-the-blog-points-at-it :END: 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. #+begin_example 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: … #+end_example 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.org=* is 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.md=* in each repo is hand-written and tiny: a badge row, one GIF, a =use-package= snippet, a short table of the main commands, and "Full write-up →". It changes rarely, so generating it from the =.org= (=ox-md= on 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=, not =holy-books=. *Migrating a repo* is then a checklist: 1. Un-=COMMENT= its heading in =projects.org=, with a =:CUSTOM_ID:= unlike the repo's name. 2. Tag repo-only plumbing ---the package preamble, the test suite, the CI YAML--- =:noexport:=: it still tangles, but never reaches readers. 3. Make the tangle a CI-checked fixpoint, lest someone patch the =.el= directly: CI re-tangles the =.org= and fails if =git diff --exit-code= finds the committed =.el= differs; see =holy-books.org='s "Testing" heading. 4. 〔C-x C-s〕 in =projects.org= and eyeball the preview. *** A REDIRECT tracks =master=: vendoring, freshness, and staleness :PROPERTIES: :CUSTOM_ID: vendoring-redirects-so-ci-can-resolve-them :END: 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.org= stays as written, so every publish re-fetches the tip of the repo's default branch ---=master=, or =main= for 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-file= would happily vendor GitHub's =404: Not Found= page as our article! So we fetch with =curl --fail=, which also sidesteps url.el's cache and its bodiless =304 Not Modified= replies. 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-books= don't; so CI also rebuilds daily ---see =schedule:= in §the CI workflow--- bounding the lag at a day. =workflow_dispatch= is there for the impatient: we force a rebuild by /Run workflow/ on the blog's Actions tab, or in a terminal by =gh 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>.org= is 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. #+begin_src shell :tangle no #!/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 #+end_src 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. *** Making a source include-ready :PROPERTIES: :CUSTOM_ID: making-a-source-include-ready :END: Three things that are right in the repo are wrong in the blog, so =blog--prepare-redirect= fixes them in the vendored copy: 1. *Relative links.* =[[file:images/short_example.png]]= is relative to the repo, but once vendored it'd resolve against =resources/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. 2. *File-level keywords.* The temp file already opens with the heading's =#+title:= and friends; an included =#+title: holy-books= would be concatenated onto ours, and =#+options: toc:nil= would hide our Ξ table of contents. So we axe =#+title:=, =#+subtitle:=, =#+author:=, =#+date:=, =#+options:=, and =#+export_file_name:=; and =#+toc:=, since Ξ already is one. 3. *=#+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, and =blog--info-subtree= reads 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. Hence =blog-publish-all= vendors 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! #+begin_src org :tangle no ,* 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: #+end_src 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. *** A REDIRECT to a container :PROPERTIES: :CUSTOM_ID: a-redirect-to-a-container :END: =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. #+begin_src org :tangle no ,#+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 ⋮ #+end_src - *Registry.* =blog--info-multiple= swaps such a heading's info for its vendored source's article infos, via =blog--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, so =blog--prepare-redirect= rewrites 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. #+begin_src emacs-lisp (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))))))) #+end_src ** SITE_NAV: data-driven site navigation from container subtrees :details: :PROPERTIES: :CUSTOM_ID: site-nav-section :END: A container heading with a =:SITE_NAV:= property is a /site page/, not a post: it joins =blog-pages= as well as =blog-posts=, and is linked from the header of every page. For instance, #+begin_src org :tangle no ,* AlBasmala :emacs:lisp: :PROPERTIES: :DATE: <2020-01-01> :DESCRIPTION: How this blog works. :CUSTOM_ID: albasmala :SITE_NAV: AlBasmala :END: #+end_src =(blog--refresh-posts)= rebuilds the header via =blog--rebuild-preamble=, its links in the order the headings appear in their containers. * Writing articles: =my/blogging-mode= :PROPERTIES: :CUSTOM_ID: writing-articles :END: Opening any =.org= file under =~/blog/= turns on =my/blogging-mode=; from then on 〔C-x C-s〕 is not merely “save” but “stamp, validate, save, and preview”. ** The mode and its keys :PROPERTIES: :CUSTOM_ID: the-mode-and-its-keys :END: 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. #+begin_src emacs-lisp (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 ")) #+end_src The modified-stamp decides whether RSS readers hear about an edit; see §RSS feeds. #+begin_src emacs-lisp (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))))))) #+end_src ** =.dir-locals.el=: auto-activate =my/blogging-mode= for all blog files :details: :PROPERTIES: :CUSTOM_ID: dir-localsel-auto-activate-myblogging-mode-for-all-blog-files :END: 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. #+begin_src emacs-lisp (provide 'AlBasmala) #+end_src #+begin_src emacs-lisp :tangle ".dir-locals.el" ((org-mode . ((eval . (progn (require 'AlBasmala (expand-file-name "AlBasmala" (locate-dominating-file default-directory ".dir-locals.el"))) (my/blogging-mode 1)))))) #+end_src ** /Seamlessly/ Previewing Articles /within/ Emacs 😲 :PROPERTIES: :CUSTOM_ID: seamlessly-previewing-articles-within-emacs :END: Every save, 〔C-x C-s〕, shows the final product ---image, abstract, header, footer, and all styles--- in a JavaScript-capable browser /within/ Emacs; ≈WYSIWYG≈. Under =my/blogging-mode=, 〔C-x C-s〕 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. *** Previews: export, then show in an xwidget :details: :PROPERTIES: :CUSTOM_ID: inserting-org-linkblog-emseamlesslyem-via-the-export-process-emthenem-preview-with-every-save :END: Previews open in an =xwidget-webkit= buffer, so Emacs must be built with xwidgets; on macOS: #+begin_src shell :tangle no brew reinstall emacs-plus@30 --with-xwidgets --with-imagemagick --with-dbus --with-debug #+end_src Confirm support is present with: =(featurep 'xwidget-internal)=. #+begin_src emacs-lisp (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)))))))) #+end_src On each 〔C-x C-s〕, a standalone =foo.org= exports beside itself as =foo.html=, and a container heading to =public/<slug>.html=. Narrowing, 〔C-x n s〕, keeps this fast. ** blog-new-article: Helper function to make a new article :details: :PROPERTIES: :CUSTOM_ID: blog-new-article-helper-function-to-make-a-new-article :END: #+BEGIN_SRC emacs-lisp -n (defun 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." (interactive) (let (file desc) ;; Subdirectories are welcome, e.g., "emacs/new-trick.org"; the blog stays flat regardless. (setq file (expand-file-name (completing-read "Filename (Above are existing): " (mapcar (lambda (f) (file-relative-name f (expand-file-name blog-posts-directory))) (blog--org-files))) blog-posts-directory)) (make-directory (file-name-directory file) t) ;; For some reason, 'find-file' in the thread above ;; wont let the completing-read display the possible completions. (find-file file) (insert "#+title: " (read-string "Title: ") "\n#+author: " user-full-name "\n#+email: " user-mail-address "\n#+modified: " (format-time-string "%Y-%m-%d") "\n#+filetags: " (s-join " " (completing-read-multiple "Tags: " blog-tags)) "\n#+fileimage: emacs-birthday-present.png" ;; "\n#+fileimage: " (completing-read ;; "Image: " ;; (mapcar #'f-filename (f-entries "~/blog/resources/"))) ;; "\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!) "\n#+description: " (setq desc (read-string "Article Purpose: ")) "\n\n* Abstract :ignore: \n" desc "\n\n* ???") (save-buffer))) #+END_SRC ** blog-new-post: Insert a heading skeleton in a multiple-style container file :details: :PROPERTIES: :CUSTOM_ID: blog-new-post-insert-a-heading-skeleton-in-a-multiple-style-container-file :END: In a container file, 〔C-RET〕 (or =M-x blog-new-post=) asks for a title, a description, and optional tags; then we write. #+begin_src emacs-lisp (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"))) #+end_src ** Media insertion: images and screenshots :details: :PROPERTIES: :CUSTOM_ID: media-insertion-images-and-screenshots :END: 〔C-c i i〕 inserts an image file, and 〔C-c i s〕 a screenshot; each lands in =resources/=, =git add=-ed. #+begin_src emacs-lisp (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)))))) #+end_src * From Org files to post-alists :PROPERTIES: :CUSTOM_ID: post-alists :END: 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, #+begin_src emacs-lisp :tangle no (("file" . "HeytingAlgebra") ("title" . "…") ("date" . "2026-05-04") ("tags" . "math lisp") ("image" . "…") ("description" . "…") ("url" . "https://alhassy.com/HeytingAlgebra") …) #+end_src =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. ** Basic facts (global variables) of my blog :details: :PROPERTIES: :CUSTOM_ID: basic-facts-global-variables-of-my-blog :END: 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=. #+begin_src emacs-lisp :exports code (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)))))))) #+end_src ** blog--info: Core helper to get the plist/JSON metadata about each post :details: :PROPERTIES: :CUSTOM_ID: blog-info-core-helper-to-get-the-plistjson-metadata-about-each-post :END: #+begin_src emacs-lisp (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))))))))) #+end_src ** Convenient accessor methods: Given a JSON hashmap, get the specified key values :details: :PROPERTIES: :CUSTOM_ID: convenient-accessor-methods-given-a-json-hashmap-get-the-specified-key-values :END: 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. #+begin_src emacs-lisp ;; 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")) #+end_src *** @history: Get an HTML badge that points to the Github history of a given file name, in my blog :details: :PROPERTIES: :CUSTOM_ID: history-get-an-html-badge-that-points-to-the-github-history-of-a-given-file-name-in-my-blog :END: #+begin_src emacs-lisp (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>")) #+end_src *** @tags: Get an HTML listing of tags, as shields.io bages, associated with the given file :details: :PROPERTIES: :CUSTOM_ID: tags-get-an-html-listing-of-tags-as-shieldsio-bages-associated-with-the-given-file :END: Org forbids dashes in tags, so we write =:programming_language:= and readers see =programming-language=, on the badge and in =tag-programming-language.html=; =blog--tag-slug= does the conversion. #+begin_src emacs-lisp (defun blog--tag-slug (tag) "Convert an internal TAG name (underscores, Org-compatible) to a kebab-case slug. Org forbids dashes in tag names ---we use underscores internally and replace them with dashes for display and URLs so the reader sees kebab-case everywhere: programming_language → programming-language tag-programming_language.html → tag-programming-language.html" (s-replace "_" "-" (downcase tag))) (defun @tag-list (json) "JSON ↦ its space-separated \"tags\" as a list, sans empty strings." (s-split " " (map-elt json "tags") t)) (defun @tags (json) "Get an HTML listing of tags, as shields.io badges, associated with the given file. Tag names are stored with underscores (Org syntax requirement) but rendered with dashes everywhere ---display label, URL slug--- so readers see kebab-case. Example use: (@tags (seq-elt blog-posts 0)) " (concat ;; Badges implementation (concat (format "<a href=\"https://alhassy.github.io/tags.html\"> %s </a>" ;; GitHub's “tag” OctoIcon; org-special-block-extras axed its octoicon: link. "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 15 16\" width=\"15\" height=\"16\"><path fill-rule=\"evenodd\" d=\"M7.73 1.73C7.26 1.26 6.62 1 5.96 1H3.5C2.13 1 1 2.13 1 3.5v2.47c0 .66.27 1.3.73 1.77l6.06 6.06c.39.39 1.02.39 1.41 0l4.59-4.59a.996.996 0 000-1.41L7.73 1.73zM2.38 7.09c-.31-.3-.47-.7-.47-1.13V3.5c0-.88.72-1.59 1.59-1.59h2.47c.42 0 .83.16 1.13.47l6.14 6.13-4.73 4.73-6.13-6.15zM3.01 3h2v2H3V3h.01z\"></path></svg>") (s-join " " (--map (let ((slug (blog--tag-slug it))) (org-link/badge (format "|%s|grey|%stag-%s.html" slug (blog--url) slug) nil 'html)) (@tag-list json)))))) #+end_src *** @image :details: :PROPERTIES: :CUSTOM_ID: image :END: <<Images>> Every article has an image ^_^ ---a =resources/= filename, a path, or a URL; =emacs-birthday-present.png= otherwise. #+html: <center> <img src="resources/emacs-birthday-present.png" width=100 height=100> </center> 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. #+begin_src emacs-lisp (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)) #+end_src #+begin_src emacs-lisp (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))))))) #+end_src ** blog--article-style, blog--make-slug: Utilities for the =multiple= article style :details: :PROPERTIES: :CUSTOM_ID: blog-article-style-blog-make-slug-utilities-for-the-multiple-article-style :END: =blog--article-style= reads =#+article_style:=; 〔C-x C-s〕, =blog-publish-all=, and =blog-new-post= dispatch on it. A slug names both =<slug>.html= and =alhassy.com/<slug>=; see §slugs. #+begin_src emacs-lisp (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 "^-\\|-$" ""))) #+end_src #+begin_src emacs-lisp (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)) #+end_src *** Tag-based image selection: =blog--image-for-tags= :PROPERTIES: :CUSTOM_ID: tag-based-image-selection-blog-image-for-tags :END: A heading without an =:IMAGE:= gets one from its tags: =blog--image-for-tags= takes the first match in =blog-tag-image-alist=, else =emacs-birthday-present.png=. =blog-new-post= pre-fills its =:IMAGE:= line the same way. Entries are =(tag . "image-spec")=, the spec being anything =#+fileimage:= accepts; e.g., =filename=, a URL, or =filename w h=. #+begin_src emacs-lisp (defvar blog-tag-image-alist '(("emacs" . "./resources/emacs-birthday-present.png 350 350") ("lisp" . "./resources/emacs-birthday-present.png 350 350") ("org" . "./resources/org_logo.png 350 350") ("haskell" . "./resources/haskell-logo.png 350 350") ("java" . "./resources/modern-java.png 350 350") ("arabic" . "./resources/arabic-irab.png 350 350") ("life" . "./resources/musa_pink.jpg 350 350") ("family" . "./resources/family-tree.png 350 350") ("karate" . "./resources/fukyu-kata.png 350 350")) "Alist mapping Org heading tags to default image specs for blog posts. First match wins. The image spec is anything #+fileimage: accepts. Used by blog--image-for-tags to avoid requiring an explicit :IMAGE: property on every container sub-article.") (defun blog--image-for-tags (tags) "Return a default image spec for the given list of TAGS (strings, lowercased). Checks blog-tag-image-alist in order; returns the first match. Falls back to the global default image when no tag matches." (or (cdr (seq-find (lambda (pair) (member (car pair) tags)) blog-tag-image-alist)) "./resources/emacs-birthday-present.png 350 350")) #+end_src ** blog--info-subtree, blog--info-multiple: Extract metadata from container headings :details: :PROPERTIES: :CUSTOM_ID: blog-info-subtree-blog-info-multiple-extract-metadata-from-container-headings :END: 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"=. #+begin_src emacs-lisp (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))))) #+end_src ** blog-posts, blog-tags, and their consumers: tag & index page generation :details: :PROPERTIES: :CUSTOM_ID: blog-posts-blog-tags-and-their-consumers-tag-index-page-generation :END: - =blog-posts= ---every post-alist from the =.org= files under =~/blog/=, newest first; =blog-make-index-page= turns each into a card on the index and tag pages. - =blog-tags= ---the sorted, unique tags of =blog--listed-posts=, which omits §drafts; for tag pages and completion. Listings ---the index, tag pages, and RSS--- draw on =blog--listed-posts= too. =(blog--refresh-posts)= sets both whenever =my/blogging-mode= starts, on every save, and atop every publish. Every post-alist enters through =blog--file-infos=, so that is where we check it. =date-to-time= never complains: =#+modified: Fri= silently becomes 1970, and =2023-13-45= some other day, misplacing the article on the index and in the RSS feed. Hence an =lf-assert= that each date names a real calendar day; on failure it shows the source file, title, and offending date at once. #+begin_src emacs-lisp -n (defun blog--date-p (str) "STR ↦ non-nil iff it names a real calendar day; e.g., “2026-05-04 Mon” ↦ t, “Fri” ↦ nil, “2023-02-30” ↦ nil." (pcase-let ((`(,_ ,_ ,_ ,day ,month ,year) (parse-time-string (or str "")))) (and day month year (<= 1 month 12) (<= 1 day (date-days-in-month year month))))) (defvar blog--file-infos-cache (make-hash-table :test #'equal) "FILE ↦ (MTIME . INFOS): what `blog--file-infos' last computed for FILE.") (defun blog--file-infos (file) "FILE ↦ its post infos (nil when unpublishable), recomputed only when FILE's mtime moves. C-x C-s refreshes the registry on every save; with the cache that costs one `file-attributes' per file, re-reading only the files that actually changed." (let ((mtime (file-attribute-modification-time (file-attributes file))) (hit (gethash file blog--file-infos-cache))) (if (and hit (equal mtime (car hit))) (cdr hit) (let ((infos (when (blog--publishable-p file) ;; Each info remembers its source's path, e.g., "emacs/foo.org", for error messages. (let ((source (file-relative-name file (expand-file-name blog-posts-directory)))) (mapcar (lambda (info) (cons (cons "source" source) info)) (if (blog--multiple-style-p file) (blog--info-multiple file) (list (blog--info file)))))))) (dolist (info infos) (lf-assert (map-elt info "source") t (@title info) t (@date info) (blog--date-p it))) (puthash file (cons mtime infos) blog--file-infos-cache) infos)))) (defun blog--compute-posts-and-pages () "Scan every .org file under `blog-posts-directory' and return (posts . pages). Every .org file is processed uniformly ---there is no special-cased directory. Container files (#+article_style: multiple) yield many post entries; standalone files yield one. A file with #+site_nav: contributes a nav-page entry instead of (or in addition to) a post entry. posts ---all entries, sorted newest-first. pages ---site_nav entries, unsorted (used for the nav bar)." (let ((posts '()) (pages '())) (dolist (file (blog--org-files)) (dolist (info (blog--file-infos file)) (when (map-elt info "site_nav") (push info pages)) (push info posts))) (cons (sort posts (lambda (a b) (time-less-p (date-to-time (@date b)) (date-to-time (@date a))))) pages))) (defun blog--rebuild-preamble () "Regenerate blog-page-preamble from blog-pages. Called automatically by blog--refresh-posts; also useful to call interactively after editing :SITE_NAV: headings." (setq blog-page-preamble (concat "<div class=\"header\">\n" " <a href=\"https://alhassy.github.io/\" class=\"logo\">Life & Computing Science</a>\n" " <br/>\n" (mapconcat (lambda (p) (format " <a href=\"%s\">%s</a>\n" (@url p) (map-elt p "site_nav"))) blog-pages "") ;; A red badge on any live page means CI failed, so the site lags master. " <br/><a href=\"https://github.com/alhassy/alhassy.github.io/actions/workflows/ci.yml\">" "<img style=\"vertical-align:middle\" alt=\"build status\"" " src=\"https://github.com/alhassy/alhassy.github.io/actions/workflows/ci.yml/badge.svg?branch=master\"></a>\n" "</div>"))) (defun blog--refresh-posts () "Recompute blog-posts, blog-pages, and blog-tags from source org files." (let ((result (blog--compute-posts-and-pages))) (setq blog-posts (car result)) (setq blog-pages (cdr result)) (setq blog-tags (sort (seq-uniq (mapcan #'@tag-list (blog--listed-posts))) #'string<)) (blog--rebuild-preamble))) (defun blog--listed-posts () "`blog-posts' sans drafts: what the index, tag pages, and RSS feeds list." (seq-remove #'@draft? blog-posts)) (defvar blog-page-preamble "" "HTML injected at the top of every exported page: the site nav bar. Set by blog--rebuild-preamble whenever blog-pages changes.") (defvar blog-page-header "" "HTML injected into the <head> of every exported page: CSS, JS, MathJax, etc. Set once at load time by the setq block near the blog-banner section.") (defvar blog-posts nil "All post metadata, sorted newest-first. Initialized by `my/blogging-mode' and `blog-publish-all'; refresh with (blog--refresh-posts).") (defvar blog-pages nil "Site navigation page metadata (subtrees with :SITE_NAV: t). These appear as header links on every page but not as blog post cards. Initialized by `my/blogging-mode' and `blog-publish-all'; refresh with (blog--refresh-posts).") (defvar blog-tags nil "Tags for my blog articles. Initialized by `my/blogging-mode' and `blog-publish-all'; refresh with (blog--refresh-posts).") #+end_src ** Validation: unique slugs :details: :PROPERTIES: :CUSTOM_ID: validation-unique-slugs :END: It enforces §the slug rules on every save and first thing in =blog-publish-all=. #+begin_src emacs-lisp (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))))) #+end_src * Exporting an article: the =blog= backend :PROPERTIES: :CUSTOM_ID: exporting-an-article :END: The pieces that turn an Org file into a blog page: + =my/blogging-mode= ---〔C-x C-s〕 previews /any/ Org file, blog or not, with the blog's CSS/JS stack. + The =blog= export 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. ** blog--header-html: the site nav, styles, and title atop every article :details: :PROPERTIES: :CUSTOM_ID: org-linkblog-using-my-blog-styles-anywhere :END: 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. #+begin_src emacs-lisp :exports code (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))))) #+end_src ** The =blog= export backend: header, tags, image, badges, and footer :details: :PROPERTIES: :CUSTOM_ID: blog-style-setup-a-function-to-insert-org-linkblog-into-a-buffer :END: 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〕, whose links to the rest of the file dangle by design; =blog--style-setup= tolerates 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. #+begin_src emacs-lisp ;; 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) #+end_src ** Article Footer: HTMLized Source and Git History :details: :PROPERTIES: :CUSTOM_ID: article-footers :END: <<footers>> #+begin_src emacs-lisp -n (defun blog--badges-bar (post post-file-name) "Return the Source/History/BuyMeACoffee badge cluster for POST. POST is the metadata alist from `blog--info'; POST-FILE-NAME is the article's filesystem path (used only when we need to htmlize a standalone article's source for the Source badge). Surfaced directly under the article image in `blog--inner-template' so readers see the source/history/support links up top ---nobody scrolls to the bottom." (let ((source-badge (if-let (url (@htmlized_source_url post)) ;; Container sub-article: source htmlized separately to <slug>.org.html. (blog--source-badge url) ;; Standalone: htmlize and return badge. (blog--htmlize-file post-file-name)))) (concat "<center>" source-badge " " (@history post) " " "<a href=\"https://www.buymeacoffee.com/alhassy\"><img src=" "\"https://alhassy.com/resources/badge-buy-me-a-coffee.svg\">" "</a>" "</center>"))) (defun blog--footer (post-file-name) "Return the closing HTML appended to every post. Just the emacs/org credit, license, Arabic-font CSS shim, and RR.js hook: the Source/History/BuyMeACoffee badges have been hoisted up to sit directly under the article image (see `blog--badges-bar'). For container sub-articles, #+htmlized_source_url: and #+history_url: are carried on the temp buffer by `blog--info'." (concat "<hr>" "<center>" (blog--css-arabic-font-setup) "<strong> Generated by Emacs and Org-mode (•̀ᴗ•́)و </strong>" (blog--license) "</center>" "<div hidden> <div id=\"postamble\" class=\"status\"> </div> </div>" (blog--read-remaining-js))) #+end_src ** Colourised source: one consistent theme :PROPERTIES: :CUSTOM_ID: colourised-source-one-consistent-theme :END: 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. #+begin_src emacs-lisp ;; 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))) #+end_src ** blog--htmlize-file: Generate an htmlized version of a given source file; return an HTML badge linking to the colourised file :details: :PROPERTIES: :CUSTOM_ID: blog-htmlize-file-generate-an-htmlized-version-of-a-given-source-file-return-an-html-badge-linking-to-the-colourised-file :END: 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. #+begin_src emacs-lisp ;; 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"))) #+end_src ** blog--license: HTML for Creative Commons Attribution-ShareAlike 3.0 Unported License :details: :PROPERTIES: :CUSTOM_ID: blog-license-html-for-creative-commons-attribution-sharealike-30-unported-license :END: #+begin_src emacs-lisp (defun blog--license () "Get HTML for Creative Commons Attribution-ShareAlike 3.0 Unported License." (s-collapse-whitespace (s-replace "\n" "" " <center style=\"font-size: 12px\"> <a rel=\"license\" href=\"https://creativecommons.org/licenses/by-sa/3.0/\"> <img alt=\"Creative Commons License\" style=\"border-width:0\" src=\"https://alhassy.com/resources/cc-by-sa-3.0-88x31.png\"/> </a> <br/> <span xmlns:dct=\"https://purl.org/dc/terms/\" href=\"https://purl.org/dc/dcmitype/Text\" property=\"dct:title\" rel=\"dct:type\"> <em>Life & Computing Science</em> </span> by <a xmlns:cc=\"https://creativecommons.org/ns#\" href=\"https://alhassy.github.io/\" property=\"cc:attributionName\" rel=\"cc:attributionURL\"> Musa Al-hassy </a> is licensed under a <a rel=\"license\" href=\"https://creativecommons.org/licenses/by-sa/3.0/\"> Creative Commons Attribution-ShareAlike 3.0 Unported License </a> </center>"))) #+end_src ** blog--read-remaining-js: HTML to use ReadRemaining.js :details: :PROPERTIES: :CUSTOM_ID: blog-read-remaining-js-html-to-use-readremainingjs :END: #+begin_src emacs-lisp (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>")) #+end_src 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. ** Link types and glossaries shared by every article :PROPERTIES: :CUSTOM_ID: link-types-and-glossaries-shared-by-every-article :END: A link type, or glossary, that one article defines for itself is invisible to CI, which never runs an article's own code; its links would then be broken. So the ones our articles share live here. =arabic:⟨phrase⟩= shows one of six classroom phrases as a small inline sign, linking to its source and translated in a tooltip; e.g., =[[arabic:Let's take a break]]=. An unknown phrase is an error, not a broken image. Arabic Roots explains its design. #+name: arabic-link #+begin_src emacs-lisp (org-deflink arabic (label) "Show one of 6 hardcoded phrases as a small inline image." (-let [url (pcase label ("Let's take a break" "https://alhassy.com/resources/iraqi-sign-take-a-break.png") ("Yes" "https://alhassy.com/resources/iraqi-sign-yes.png") ("No" "https://alhassy.com/resources/iraqi-sign-no.png") ("Agree" "https://alhassy.com/resources/iraqi-sign-agree.png") ("Disagree" "https://alhassy.com/resources/iraqi-sign-disagree.png") ("I have a question" "https://alhassy.com/resources/iraqi-sign-question.png") (_ (error "arabic:%s is not one of: Let's take a break, Yes, No, Agree, Disagree, I have a question" label)))] (format "<a href=\"%s\" class=\"tooltip\" title=\"%s\"><img src=\"%s\" height=50></a>" url label url))) #+end_src The Arabic articles cite =doc:arabic-root= and friends, from the glossary of Arabic linguistic terms; registering it here lets every article, on every machine, resolve them. #+begin_src emacs-lisp (add-to-list 'org-docs-libraries (expand-file-name "arabic-glossary.org" blog-posts-directory)) #+end_src * Look and Feel :PROPERTIES: :CREATED: [2026-10-01 Thu 21:25] :CUSTOM_ID: look-and-feel :END: What every page's =<head>= loads: + =blog-page-header=: our self-hosted CSS, under =resources/=, and =<script>= fragments; §the-html-header. + A floating table of contents, §floating-toc, and clickable headings with stable anchors. + MathJax, §MathJax-Support, and Arabic fonts. ** /Style/! ✨ What do we want to be inserted into the head of every page? :PROPERTIES: :CUSTOM_ID: html-header :END: <<the-html-header>> Every page loads the styles and ~<script>~ fragments below; we tangle each where it belongs. *** Style Header Elements :PROPERTIES: :CUSTOM_ID: style-header-elements :END: Firstly, we want some styling rules to be loaded. #+begin_src emacs-lisp -r -n :noweb-ref my-html-header :tangle no (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\" />" (ref:usualCSS) "<link href=\"https://alhassy.com/resources/org-notes-style.css\" rel=\"stylesheet\" type=\"text/css\" />" (ref:orgNotesCSS) "<link href=\"https://alhassy.com/resources/floating-toc.css\" rel=\"stylesheet\" type=\"text/css\" />" (ref:tocCSS) "<link href=\"https://alhassy.com/resources/blog-banner.css\" rel=\"stylesheet\" type=\"text/css\" />" (ref:bannerCSS) "<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\">") #+end_src + usual-org-front-matter.css badge:||success|resources/usual-org-front-matter.css|css3 :: 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 badge:||success|resources/org-notes-style.css|css3 :: We vendor the rose-style org-notes-style, since hot-linking it from its homepage fails. + floating-toc.css badge:||success|resources/floating-toc.css|css3 :: I want to have an unobtrusive floating table of contents, see §floating-toc. + blog-banner.css badge:||success|resources/blog-banner.css|css3 :: Finally, we want a beautiful welcome mat, see §blog-banner. *** Script Header Elements :PROPERTIES: :CUSTOM_ID: script-header-elements :END: In addition, we have two more pieces we would like to add to the header: Support for /dynamic/ code-line highlighting, §blog-banner, and support for using LaTeX-style notation to write mathematics, §MathJax-Support. We will use a noweb-ref named =my-html-header= to refer to them, which are then catenated below. #+begin_details "Full, tangled, value of blog-page-header" #+begin_src emacs-lisp -r :noweb yes :results raw -n (setq blog-page-header (concat ;; NOPE: org-html-head-extra ;; Altered by 'org-special-block-extras' <<my-html-header>> )) #+end_src # Using "html-header" as the noweb-ref caused the entirrity of the source # block, along with the #+begin…#+end to be included. :MetaRemark_about_above_lisp: The noweb-ref invocation =l <<𝓍𝓈>> r= expands into #+begin_src emacs-lisp :tangle no :noeval l 𝓍₀ r l 𝓍₁ r ⋮ l 𝓍ₙ r #+end_src Where the =𝓍ᵢ= are the lines referenced by =𝓍𝓈=. *As such, we had our reference call, above, in its own line!* :End: #+end_details *** Blog Banner and Dynamic Code Highlighting :PROPERTIES: :CUSTOM_ID: blog-banner :END: <<blog-banner>> I want to have a nice banner at the top of every page, which should link to useful parts of my blog. Its links are data: each container subtree marked =:SITE_NAV: t= appears in the banner; see §SITE_NAV. I want to style it as follows: + Line (headerHeader): The banner is in a box at the top with some shadowing and centerd text using the =fantasy= font + Line (headerLogo): The blog's title is large and bold + Line (headerAnchor): All links in the banner are black + Line (headerHover): When you hover over a link, it becomes blue #+begin_details CSS Details #+begin_src css -r -n :tangle resources/blog-banner.css :noeval -n .header { (ref:headerHeader) /* fantasy first (Papyrus on Chrome/Safari); Philosopher as cross-browser fallback. */ font-family: fantasy, 'Philosopher', 'Book Antiqua', Palatino, serif; text-align: center; overflow: hidden; /* background-color: #f1f1f1 !important; */ /* background: #4183c4 !important; */ padding-top: 10px; padding-bottom: 10px; box-shadow: 0 2px 10px 2px rgba(0, 0, 0, 0.2); } .header a.logo { (ref:headerLogo) font-size: 50px; font-weight: bold; } .header a { (ref:headerAnchor) color: black; padding: 12px; text-decoration: none; font-size: 18px; } .header a:hover { (ref:headerHover) background-color: #ddd; background-color: #fff; color: #4183c4; } #+end_src #+end_details Notice that as you hover over the references, such as this, *the corresponding line of code is highlighted!* Within a =src= block, one uses the switches =-n -r= to enable references via line numbers, then declares ~(ref:name)~ on line and refers to it by =[[(name)][description]]=. Org-mode by default styles such highlighting. #+begin_details Dynamic Code Highlighting #+begin_src emacs-lisp -n :noweb-ref my-html-header :tangle no "<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>" #+end_src #+end_details *** Miscellaneous Styles :PROPERTIES: :CUSTOM_ID: miscellaneous-styles :END: Smaller touches follow: curvy source blocks, pink inline code and tables, and greenish folded regions. *** Curvy Source Blocks & Pink Inline :details: :PROPERTIES: :CUSTOM_ID: curvy-source-blocks-pink-inline :END: <<curvy-blocks>> The =border-radius= property defines the radius of an element's corners, we use it to make curvy looking source blocks. Its behaviour changes depending on how many arguments it is given. - We also style the code block's label to be curvy. - Both =.src= and =pre.src:before= are used by Org. #+begin_src css -r -n :tangle resources/blog-banner.css :noeval -n .src { border: 0px !important; /* 50px for top-left and bottom-right corners; 20px for top-right and bottom-left cornerns. */ border-radius: 50px 20px !important; } pre.src:before { /* border: 0px !important; */ /* background-color: inherit !important; */ padding: 3px !important; border-radius: 20px 50px !important; font-weight:700 } /* wrap lengthy lines for code blocks */ pre{white-space:pre-wrap} /* Iosevka Slab (SIL OFL), self-hosted and subset to Latin, Greek, arrows & maths; à la ogbe.net */ @font-face { font-family: "Iosevka Slab Web"; font-display: swap; font-weight: normal; font-style: normal; src: url('fonts/iosevka-slab-regular.woff2') format('woff2') } @font-face { font-family: "Iosevka Slab Web"; font-display: swap; font-weight: bold; font-style: normal; src: url('fonts/iosevka-slab-bold.woff2') format('woff2') } @font-face { font-family: "Iosevka Slab Web"; font-display: swap; font-weight: normal; font-style: italic; src: url('fonts/iosevka-slab-italic.woff2') format('woff2') } pre, code { font-family: "Iosevka Slab Web", monospace !important; } /* Inline code with ~ ⋯ ~ and = ⋯ = sits in its paragraph: the slab face on a faint blush ---the old pink, whispering */ code { background: rgba(255, 192, 203, 0.3) !important; border-radius: 3px; padding: 0 0.2em; font-size: 0.95em; } #+end_src 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. *** Pink Tables :details: :PROPERTIES: :CUSTOM_ID: pink-tables :END: #+begin_src css :tangle resources/blog-banner.css :noeval -n 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; } #+end_src #+caption: Example table | Prime | 2^{Prime} | |-------+-----------| | <c> | <c> | | 1 | 2 | | 2 | 4 | | 3 | 8 | | 5 | 32 | | 7 | 128 | | 11 | 2048 | #+TBLFM: $2='(expt 2 $1);N # For the line wrapping, it may be useful to have # =#+PROPERTY: header-args -n= at the top of the file # to have all blocks displayed with line numbers. Table captions should be below the tables; the =blog= backend's =:html-table-caption-above= default (§The blog export backend) sees to that. *** Let's show folded, details, regions with a nice greenish colour :details: :PROPERTIES: :CUSTOM_ID: lets-show-folded-details-regions-with-a-nice-greenish-colour :END: This is part of =org-special-block-extras=, and it's something like this: #+begin_src css :tangle resources/blog-banner.css :noeval -n 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; } #+end_src ** Ξ: Floating /Table of Contents/ :PROPERTIES: :CUSTOM_ID: floating-toc :END: <<floating-toc>> 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. #+html: <br><center> ★ ★ ★ </center><br> When we write =#+toc: headlines 2= in our Org, HTML export produces the following. #+begin_src html -n :exports code :tangle no :noeval <div id="table-of-contents"> <h2>Table of Contents</h2> <div id="text-table-of-contents"> <ul> <li> section 1 </li> ⋮ <li> section 𝓃 </li> </ul> </div> </div> #+end_src 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. #+begin_details "CSS for a floating TOC" #+begin_src css -n :tangle resources/floating-toc.css :noeval /*TOC inspired by https://orgmode.org/worg/ */ #table-of-contents { /* Place the toc in the top right corner */ position: fixed; right: 0em; top: 0em; margin-top: 120px; /* offset from the top of the screen */ /* It shrinks and grows as necessary */ padding: 0em !important; width: auto !important; min-width: auto !important; font-size: 10pt; background: white; line-height: 12pt; text-align: right; box-shadow: 0 0 1em #777777; -webkit-box-shadow: 0 0 1em #777777; -moz-box-shadow: 0 0 1em #777777; -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; /* Ensure doesn't flow off the screen when expanded */ max-height: 80%; overflow: auto;} /* How big is the text "Table of Contents" and space around it */ #table-of-contents h2 { font-size: 13pt; max-width: 9em; border: 0; font-weight: normal; padding-left: 0.5em; padding-right: 0.5em; padding-top: 0.05em; padding-bottom: 0.05em; } /* Intially have the TOC folded up; show it if the mouse hovers it */ #table-of-contents #text-table-of-contents { display: none; text-align: left; } #table-of-contents:hover #text-table-of-contents { display: block; padding: 0.5em; margin-top: -1.5em; } #+end_src #+end_details # /* TOC entries, unnumbered lists, should not be indented too much */ # #text-table-of-contents ul { padding-left: 20px } 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 =Ξ=. #+begin_src emacs-lisp -n (defun blog--toc-title () "The TOC's heading: a Ξ that scrolls back to the top of the page." (s-collapse-whitespace " <a href=\"javascript:window.scrollTo(0,0)\" style=\"color: black !important; border-bottom: none !important;\" class=\"tooltip\" title=\"Go to the top of the page\"> Ξ </a> ")) #+end_src How did I get here? 1. How does Org's HTML export TOCs? ⇒ doc:org-html-toc 2. Looking at its source, it emits =<h2>Table of Contents</h2>= ---the phrase coming from the internal doc:org-html--translate. 3. The =blog= backend's =inner-template= swaps that phrase for "Ξ" ---in our output alone. 4. Joy ♥‿♥ # ( The Unicode whitespace ' ' before and after =Ξ= is to appease the clickable headlines utility, below. ) ** Clickable Sections with Sensible Anchors :PROPERTIES: :CUSTOM_ID: clickable-sections-with-sensible-anchors :END: *** Ensuring Useful HTML Anchors :PROPERTIES: :CUSTOM_ID: ensuring-useful-html-anchors :END: 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-s〕 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. #+begin_details "blog--ensure-useful-section-anchors: Run on every C-x C-s" #+BEGIN_SRC emacs-lisp (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. #+END_SRC #+end_details 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. *** Clickable Headlines :PROPERTIES: :CUSTOM_ID: clickable-headlines :END: 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. #+begin_details org-html-format-headline-function #+begin_src emacs-lisp ;; 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))) #+end_src #+end_details =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. ** MathJax Support ---$e^{i \cdot \pi} + 1 = 0$ :PROPERTIES: :CUSTOM_ID: mathjax-support :END: <<MathJax-Support>> Org loads the MathJax display engine for mathematics whenever users write LaTeX-style math delimited by ~$...$~ or by =\[...\]=. Here is an example. #+begin_org-demo \[ 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! #+end_org-demo *** Unicode Warning! :PROPERTIES: :CUSTOM_ID: unicode-warning :END: 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. #+begin_org-demo \[ p ⊑ q \quad ≡ \quad p ⊓ q = p \tag{⊑-Definition}\label{⊑-Definition} \] \[ p ⊑ q \quad ≡ \quad p ⊔ q = q \tag{⊑-Definition}\label{Order-Definition} \] #+end_org-demo *** Rule Resurrection :PROPERTIES: :CUSTOM_ID: rule-resurrection :END: The following rule for anchors =a {⋯}= resurrects =\ref{}= calls via MathJax ---which =org-notes-style= kills. #+begin_src css :tangle resources/blog-banner.css :noeval a { white-space: pre !important; } #+end_src *** /Making Math Stick-out with Spacing!/ :PROPERTIES: :CUSTOM_ID: comment-nice-math-spacing :END: Notice how the /math expressions/ stick out in these following sentences: 1. We use $x$ as the name of the unknown. 2. The phrase $∀ x • ∃ y • x 〔R〕 y$ indicates that relation $R$ is "total". Nice, the following pads MathJax elements so that they stand out. #+HTML_MATHJAX: padding: 25px 25px *** COMMENT Example calculation from that MathJax Setup? :PROPERTIES: :CUSTOM_ID: comment-example-calculation-from-that-mathjax-setup :END: Maybe move the MathJax setup into AlBasmala directly, then include it? Or, maybe incorporate the MathJax setup via Emacs directly ♥‿♥ Such as org-html-head-extra \begin{calc} x \;⊓\; ¬ x \quad=\quad ⊥ \step{ \ref{⊑-antisymmetric} } (x \;⊓\; ¬ x) \sqleqs ⊥ \landS ⊥ \sqleqs (x \;⊓\; ¬ x) \step{ \ref{Bottom Element} } x \;⊓\; ¬ x \sqleqS ⊥ \step{ \ref{Modus Ponens} } \mathsf{true} \end{calc} Then, \[\eqn{Constructive De Morgan}{¬(x \;⊔\; y) \quad=\quad ¬ x \;⊓\; ¬ y}\] ** Arabic Font Setup :PROPERTIES: :CUSTOM_ID: arabic-font-setup :END: 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. #+begin_details #+begin_src emacs-lisp (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>") #+end_src To understand /why/ these styling rules work, see this website: Right-to-left Styling. #+end_details #+html: <br> #+begin_org-demo :source-color white :result-color white For example, + Inline: اهلاً وسهلاً + Within a table: | اهلاً وسهلاً | #+end_org-demo # Yuck! + Inline code: =اهلاً وسهلاً= As the above left source demonstrates, unless some explicit action is taken, Arabic fonts are by default rendered hideously small. * Index, tag pages, and RSS feeds :PROPERTIES: :CUSTOM_ID: index-tags-and-rss :END: Besides one page per article, every publish regenerates =index.html=, one =tag-⟨tag⟩.html= per tag, and their RSS feeds ---all from =blog-posts=. ** Generating the Index Page :details: :PROPERTIES: :CUSTOM_ID: generating-the-index-page :END: <<Index>> doc:blog-make-index-page renders ~index.html~: one card per article, with its title, date, image, abstract, and a read-more badge. #+begin_src emacs-lisp -n (defun blog--greeting (&optional tag) "Return the index/tag-page greeting string, optionally specialised to TAG. The `thread-first` / `doc:cl-loop' name-drop makes sense on the landing page (Elisp is the site's /lingua franca/) but reads as a non-sequitur on a tag-scoped page like `life' ---so tag pages get a trimmed variant." (if tag (format "Here are some of my latest thoughts on %s... badge:Made_with|Lisp|success|https://alhassy.github.io/ElispCheatSheet/CheatSheet.pdf|Gnu-Emacs tweet:https://alhassy.com @@html:<br><br>@@" (blog--tag-slug tag)) "Here are some of my latest thoughts... badge:Made_with|Lisp|success|https://alhassy.github.io/ElispCheatSheet/CheatSheet.pdf|Gnu-Emacs such as doc:thread-first and doc:cl-loop (•̀ᴗ•́)و tweet:https://alhassy.com @@html:<br><br>@@")) (defun blog--card (post) "Return the Org source for one article card. No Org heading is emitted ---Org would render it as an <h2> *in addition* to our own <h2 class=\"title\"> inside the export block, giving every card a duplicated title. Per-tag filtering is done in Elisp before cards are built (see `blog-make-index-page'), so the Org tag machinery earns us nothing here." (concat "#+begin_export html\n" (format "<h2 class=\"title\"><a href=\"%s\">%s</a></h2>\n" (@url post) (blog--title-html (@title post))) (format "<center>%s</center>\n" (@tags post)) (@image post (blog--url "resources/")) "\n#+end_export\n" "\n" (blog--card-abstract post) "\n" ;; badge:… is an org-special-block-extras link type, so it must sit in ;; Org territory ---not inside #+begin_export html, which would ship it ;; verbatim to the HTML output. The @@html:…@@ wrappers give us the ;; surrounding <p> while keeping the badge: link itself as Org syntax. (format "@@html:<p style=\"text-align:right\">@@ badge:Read|more|green|%s|read-the-docs @@html:</p>@@\n" (@url post)) "\n@@html:<hr>@@\n")) (defun blog--card-abstract (post) "POST ↦ its abstract, its links into the article made absolute, since the card lives on another page. [[#rss-feeds][RSS]] ↦ [[https://alhassy.com/AlBasmala#rss-feeds][RSS]], and [[blog-banner]] ↦ [[https://alhassy.com/AlBasmala#blog-banner]] Typed links, such as [[file:x.png]] or doc:thread-first, and paths such as [[./x.png]], are left alone." (replace-regexp-in-string "\\[\\[#?\\([^]:[/.~][^]:[]*\\)\\]" (lambda (link) (format "[[%s#%s]" (@url post) (match-string 1 link))) (or (@abstract post) "") t t)) (defun blog--toc-block (posts) "Return a #+begin_export html block listing every post as a linked TOC entry." (concat "#+begin_export html\n" "<details id=\"articles-toc\" style=\"text-align:center;margin:1em 0\">\n" "<summary style=\"cursor:pointer;font-size:1.1em;font-weight:bold\">Articles on this page</summary>\n" "<ol style=\"display:inline-block;text-align:left;margin-top:0.5em\">\n" (mapconcat (lambda (post) (format "<li><a href=\"%s\">%s</a></li>\n" (@url post) (blog--title-html (@title post)))) posts "") "</ol>\n</details>\n" "#+end_export\n")) (defun blog--make-page-buffer (posts greeting export-file-name &optional rss-file) "Return a fresh Org buffer for POSTS with GREETING, targeting EXPORT-FILE-NAME. RSS-FILE is the filename of the associated feed (\"rss.xml\" for the index, \"<tag>-rss.xml\" for a tag page). It is surfaced as a prominent link right under the greeting so readers see it without scrolling; nil omits it, as drafts have no feed. Caller is responsible for killing the buffer when done." (let ((buf (generate-new-buffer " *blog-page*")) (rss rss-file)) (with-current-buffer buf (insert (format "#+EXPORT_FILE_NAME: %s\n" export-file-name) "#+options: toc:nil title:nil html-postamble:nil\n" "#+begin_export html\n" blog-page-preamble "\n" blog-page-header "\n" "#+end_export\n" "#+html: <br>\n" greeting "\n" (if rss (format "#+html: <p style=\"text-align:center;\"><a href=\"%s\">📡 Subscribe via RSS</a></p>\n" rss) "") "#+html: <br>\n" (blog--toc-block posts) "#+html: <br>\n" (mapconcat #'blog--card posts "\n") "\n#+begin_export html\n" "<hr> <center> <em> Thanks for reading everything! 😁 Bye! 👋 </em>" " </center> <br/>\n" (blog--license) "\n#+end_export\n") (org-mode) ;; ospe provides the `badge:`, `doc:`, `tweet:` etc. link handlers ;; used throughout `blog--card', and appends tooltipster CSS/JS ;; to `org-html-head-extra' on first activation (idempotent: ;; ospe's setup-guard wraps the injection). (org-special-block-extras-mode 1)) buf)) (defun blog-make-index-page () "Assemble index.html, the unlisted drafts.html, and every tag page. Builds one Org buffer per output file, each populated directly from the relevant subset of blog-posts ---no copy-then-delete." (cl-flet ((export-page (posts greeting dest rss) (let ((buf (blog--make-page-buffer posts greeting dest rss))) (unwind-protect (with-current-buffer buf (org-export-to-file 'blog-page (org-export-output-file-name ".html"))) (with-current-buffer buf (set-buffer-modified-p nil)) (kill-buffer buf))))) (export-page (blog--listed-posts) (blog--greeting) (concat blog-publish-directory "index.html") "rss.xml") ;; Unlisted like the drafts it lists: linked from nowhere, noindex, and no feed. (export-page (seq-filter #'@draft? blog-posts) (concat "#+html_head_extra: <meta name=\"robots\" content=\"noindex\">\n" "Here are my drafts: published, yet unlisted ---still mulling them over. @@html:<br><br>@@") (concat blog-publish-directory "drafts.html") nil) (let ((by-tag (blog--posts-by-tag))) (dolist (tag blog-tags) (message "=> Generating tag page: %s..." tag) (let ((slug (blog--tag-slug tag))) (export-page (gethash tag by-tag) (blog--greeting tag) (concat blog-publish-directory (concat "tag-" slug ".html")) (concat slug "-rss.xml"))))))) #+end_src ** RSS feeds: one per tag, plus a global one :details: :PROPERTIES: :CUSTOM_ID: rss-feeds-one-per-tag-plus-a-global-one :END: RSS, /Really Simple Syndication/, is a plain-text format a website uses to announce its new content. A reader program ---Feedly, Inoreader, Newsboat, NetNewsWire, and dozens of others--- subscribes to the URL of a feed and periodically refetches it; when the feed has a new =<item>=, the reader surfaces it as "there is a new article". The blog does not push; readers /pull/ whenever they want. So every time we publish, this pipeline regenerates =rss.xml=; the next time a reader's program polls our URL, it compares entries against what it saw last time and tells the human about any new ones. We never send email, never track subscribers, never even know who is subscribed. Publishing is just "put a new item in the feed"; the rest is the reader's problem. Why bother at all? Because RSS is the one remaining ambient-notification mechanism for writers on the open web: no algorithm, no platform lock-in, no "follow" button owned by a company. A reader who subscribed in 2008 is still subscribed today, so long as the URL resolves. For a personal technical blog, it is the cheapest way to have an audience that returns. We emit two flavours of feed: - =rss.xml= ---every post, newest first, for the reader who wants everything we write. - =<tag>-rss.xml= ---one per tag, e.g., =emacs-rss.xml= or =arabic-rss.xml=, for the reader who wants only those articles; each is linked atop its tag page. Each item carries a =<pubDate>=, a =<link>= back to =alhassy.com/<slug>=, and the article's =#+description= as its preview text. Bodies are intentionally not embedded: we want readers to click through. *On identity and what counts as “new”.* Every =<item>= also carries a =<guid>=, a /globally unique identifier/. Feed readers remember which guids they have already shown their user, and on every refetch compare the feed's current guids against that seen-set: - /New guid/ ↦ the reader announces it as a new post. - /Already-seen guid/ ↦ the reader ignores the item, /even if its title, description, or pubDate changed/. Hence the guid, not the content, decides what our readers hear about. Ours is : alhassy.com/<slug>?last-updated=<MODIFIED> or just =alhassy.com/<slug>= without a =:MODIFIED:= (=#+modified:= for standalone posts). Hence an edit re-announces an article exactly when its =:MODIFIED:= changes. *Partial-commit trick.* =:MODIFIED:= is bumped on every save, and Magit decides the rest: stage its hunk with =s= to announce the edit, or unstage it with =u= to ship a typo fix quietly. The “should this notify?” decision lives at commit time, alongside the change. Deriving the date from =git log= instead would make every =[typo]= commit re-announce. #+begin_src emacs-lisp -n (defun blog--rss-date (date-str) "Format DATE-STR (an Org date string) as an RFC-822 pubDate for RSS." (format-time-string "%a, %d %b %Y %H:%M:%S %z" (condition-case _ (date-to-time date-str) (error (current-time))))) (defun blog--rss-guid (post) "Return POST's RSS <guid>. If POST carries a :MODIFIED: property, the guid is the article URL with a ?last-updated=<MODIFIED> query string; bumping :MODIFIED: therefore re-announces the post to every subscriber's feed reader (see the identity discussion in the prose above). Without :MODIFIED:, the guid is just the URL ---edits stay quiet until you stamp one." (let ((modified (map-elt post "modified"))) (if modified (format "%s?last-updated=%s" (@url post) modified) (@url post)))) (defun blog--make-one-rss-feed (posts filename &optional channel-title) "Emit `blog-publish-directory'/FILENAME from POSTS. A minimal RSS 2.0 feed ---title, link, pubDate, description per item. Body is intentionally a short #+description rather than the full article HTML, so readers click through to alhassy.com. CHANNEL-TITLE defaults to `blog-title'. Text fields are run through `xml-escape-string' so <, >, & in titles and descriptions do not break feed readers." (cl-flet ((esc (s) (xml-escape-string (or s "")))) (let ((dest (concat blog-publish-directory filename)) (title (or channel-title blog-title))) (with-temp-file dest (insert "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<rss version=\"2.0\">\n" "<channel>\n" "<title>" (esc title) "</title>\n" "<link>" (esc blog-url) "</link>\n" "<description>" (esc title) "</description>\n" "<language>en-us</language>\n") (dolist (post posts) (insert "<item>\n" "<title>" (esc (@title post)) "</title>\n" "<link>" (esc (@url post)) "</link>\n" "<guid>" (esc (blog--rss-guid post)) "</guid>\n" "<pubDate>" (blog--rss-date (@date post)) "</pubDate>\n" "<description>" (esc (@description post)) "</description>\n" "</item>\n")) (insert "</channel>\n</rss>\n"))))) (defun blog--posts-by-tag () "Return a hash-table mapping tag-string → list of posts carrying that tag. Each post appears under every tag it declares in #+filetags. Lookup is O(1); the table mirrors `blog-posts' and should be rebuilt after every `blog--refresh-posts' call ---cheapest to just call this fresh at each consumer (index/RSS loops)." (let ((by-tag (make-hash-table :test #'equal))) (dolist (p (blog--listed-posts)) (dolist (tag (@tag-list p)) (push p (gethash tag by-tag)))) (maphash (lambda (k v) (puthash k (nreverse v) by-tag)) by-tag) by-tag)) (defun blog--make-rss-feed () "Emit rss.xml (all posts) plus one <tag>-rss.xml per tag." (blog--make-one-rss-feed (blog--listed-posts) "rss.xml") (let ((by-tag (blog--posts-by-tag))) (dolist (tag blog-tags) (blog--make-one-rss-feed (gethash tag by-tag) (concat (blog--tag-slug tag) "-rss.xml") (format "%s — %s" blog-title tag))))) #+end_src * Publishing :PROPERTIES: :CREATED: [2026-10-01 Thu 21:25] :CUSTOM_ID: publishing :END: The build pipeline, which CI runs on every push to =master=. ** Actually publishing an article :PROPERTIES: :CUSTOM_ID: actually-publishing-an-article :END: 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 §Deployment via CI. =blog-publish-all= dispatches on =#+article_style:=: - *standalone* (the default) ---one =.org= file ↦ one HTML file. - *multiple* ---=blog--publish-multiple-articles= calls =blog--publish-single-subtree= on every non-=:noexport:= top-level heading. =blog--publish-single-subtree= turns each heading into a synthetic temp file and exports it just as a standalone article; see §How the export pipeline connects the two styles. *** blog-publish-all: CI entry point :details: :PROPERTIES: :CUSTOM_ID: blog-publish-all-ci-entry-point :END: =blog-publish-all= is what CI calls; locally, it is a full dry-run before pushing. #+begin_src emacs-lisp (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))) #+end_src *** How the export pipeline connects the two styles :PROPERTIES: :CUSTOM_ID: how-the-export-pipeline-connects-the-two-styles :END: The =blog= backend reads an article's metadata from file-level =#+keyword:= lines, via =blog--info=; a container heading keeps its metadata in a =:PROPERTIES:= drawer instead. So =blog--publish-single-subtree= turns the heading into a /synthetic temp file/: 1. =blog--info-subtree= reads the heading's metadata. 2. The temp file gets the matching file-level keywords ---=#+title:=, =#+filetags:=, =#+fileimage:=, =#+description:=--- plus =#+history_url:= and =#+htmlized_source_url:=, pointing the badges at the container's git log and the per-article colourised source. 3. The subtree body is pasted in and promoted a level; e.g., =** Abstract :ignore:= ↦ =* Abstract :ignore:=, just where =blog--style-setup= looks for it. 4. We export with the =blog= backend, as for any standalone post. 5. A narrowed copy of the subtree is htmlized into =<slug>.org.html=. Both styles thus share one export pipeline; they differ only in how they reach it. #+begin_src emacs-lisp (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))))) #+end_src #+begin_src emacs-lisp (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))) #+end_src ** Deployment via CI :PROPERTIES: :CUSTOM_ID: deployment-via-ci :END: We keep two branches: =master= holds the sources, and CI owns all the HTML. #+begin_example 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/ ... #+end_example - =master= holds only sources: =.org= files, =resources/=, and config. - Every push makes CI export every post, =index.html=, the =tag-*.html= pages, and =rss.xml= into =public/=, alongside a copy of =resources/=. - =peaceiris/actions-gh-pages= force-pushes =public/= to =gh-pages=, which GitHub Pages serves at the root; so URLs are flat: =alhassy.com/foo=. *CI rebuilds everything, every run.* A fresh checkout has no =public/=, so =blog-publish-all= exports every publishable =.org= ---no mtimes, no diffing--- and the deploy overwrites =gh-pages= wholesale. Hence a check either runs before any HTML is written, or not at all. =blog--validate-slugs= runs on =C-x C-s=, where slug clashes are cheapest to fix, and first thing in =blog-publish-all=; so a pushed-but-never-previewed edit fails the build rather than silently overwriting one page with another. - *What is deployed?* Browse the =gh-pages= branch; it is exactly what =alhassy.com= serves. - *One-time setup:* in the repo's Settings → Pages, set /Source/ to "Deploy from a branch", with branch =gh-pages= and folder =/=. Otherwise GitHub Pages serves =master=, where no HTML lives. - *Timing:* a run takes ~2 minutes, of which exporting is ~30 s; the rest is provisioning a runner and installing Emacs. Public repos get unlimited Actions minutes, so this costs nothing. *** Asset sync :details: :PROPERTIES: :CUSTOM_ID: asset-sync :END: 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/=. #+begin_src emacs-lisp (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))))))) #+end_src *** CI workflow: =.github/workflows/ci.yml= :details: :PROPERTIES: :CUSTOM_ID: ci-workflow-githubworkflowsciyml :END: This block /is/ the workflow: it tangles to =.github/workflows/ci.yml=, so any CI workaround sits beside its explanation. One =build= job 1. installs our packages 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; 2. tangles this file and loads =AlBasmala.el=; 3. runs =blog-publish-all=; and 4. on pushes to =master=, and on the daily schedule, force-pushes =public/= to =gh-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. #+begin_src yaml :tangle .github/workflows/ci.yml 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 #+end_src * Dependencies :PROPERTIES: :CUSTOM_ID: dependencies :END: 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=, and =org-deftag=. We insist on MELPA's copy, over any local development version, and on one recent enough to have =org-deftag=. + =xml=, =lf=, =ox-extra= ---escaping RSS text, =lf-assert= checks, and the =:ignore:= tag that axes a heading yet keeps its text. #+begin_src emacs-lisp :noweb-ref albasmala-dependencies :tangle no (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)) #+end_src * Appendices :PROPERTIES: :CREATED: [2026-10-01 Thu 21:26] :CUSTOM_ID: appendices :END: ** Appendix 𝐈 Using a Custom Domain: ~alhassy.com~ :PROPERTIES: :CUSTOM_ID: comment-using-a-custom-domain-alhassy-com :UNNUMBERED: t :END: #+begin_details 1. Go to your repo: https://github.com/alhassy/alhassy.github.io/settings/pages 2. Add a =Custom Domain= such as an "apex domain" like =alhassy.com= (or a www domain like =www.alhassy.com=) - Apex domains require =A= records to be setup on your DNS provider. - www domains require =CNAME= records. - On my DNS provider, I setup both: That way, with or without =www.=, people will arrive at my blog. 3. Go to your DNS provider, and add two records | Type | Name | Priority | Content | TTL | |-------+------+----------+-------------------+-------| | CNAME | www | 0 | alhassy.github.io | 14400 | | CNAME | @ | 0 | alhassy.github.io | 14400 | 4. In a terminal, run: =dig www.alhassy.com +nostats +nocomments +nocmd= - The =www.= is intentional. 5. You should see something like: #+begin_example bash 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 #+end_example This says that it may take 3600 seconds, or 1hour, for the redirect of ~alhassy.com~ to ~alhassy.github.io~ to be completed. It may take longer; keep reading. 6. In your DNS provider, add 4 records, one for each IP Address you got from the =dig= command. (These addresses are also on the official Github docs). | Type | Name | Priority | Content | TTL | |------+------+----------+-----------------+-------| | A | @ | 0 | 185.199.108.153 | 14400 | | A | @ | 0 | 185.199.109.153 | 14400 | | A | @ | 0 | 185.199.110.153 | 14400 | | A | @ | 0 | 185.199.111.153 | 14400 | 7. Run =dig alhassy.com +noall +answer -t A= and ensure it points to these IP addresses. - Notice the lack of a =www.= 8. Open an incognito browser, private browsing session, and navigate to =alhassy.com=. a. If this redirects to your =alhassy.github.io= blog, then joy! - If the redirect does not happen in your non-incognito browser, just clear your browsing history and try again. b. Otherwise, it may take some time (something like 1/2hour to 3 days) for the DNS propagation to be completed. - You can check the progress by using a service like this. - It took me about 1/2hour for the URL to redirect to my github.io blog. Further resources: - Managing a custom domain for your GitHub Pages site - GitHub Docs - Linking A Custom Domain To Github Pages #+end_details ** Appendix 𝐈𝐈 Stuff that really belongs in org-special-block-extras :PROPERTIES: :CUSTOM_ID: stuff-that-really-belongs-in-org-special-block-extras :END: *** Image Org Link :details_imagelink: :PROPERTIES: :CUSTOM_ID: image-org-link :END: A quick way to embed clickable images, along with tooltip credits and other configs. #+begin_src emacs-lisp :exports code (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)))) #+end_src *** Known bug and proposed fix. :noexport: :PROPERTIES: :CUSTOM_ID: known-bug-and-proposed-fix :END: Warning! #+begin_src org :tangle no ,* Useful notes :noexport: Be a good person. ,* Advice to readers :details: Since this section is rewritten as a <details> block, it will, by structure, reside as an element in the previous tree which is not exported! This this tree wont be visible! #+end_src TODO: Fix the above bug by ensuring all altered headings are preserved as follows. #+begin_src org :tangle no ,* original heading :FOO: becomes ,* original heading :ignore: ⦃transformed heading⦄ #+end_src This way we ensure that the new tree is completely independent of the previous tree. **** COMMENT Posterity :Delete_when_things_settle: :PROPERTIES: :CUSTOM_ID: comment-posterity :END: #+BEGIN_SRC emacs-lisp :export none (defun my-headline-alteration (backend) "BACKEND is the export back-end being used, as a symbol." (setq first-heading t) (outline-show-all) (org-map-entries (lambda () (kill-line) ;; (thing-at-point 'line) (let ((heading (car kill-ring))) (if (not (s-contains? ":NOPE:" heading 'ignoring-case)) (insert heading) (insert heading) (insert "NIIIIICE") ;; (if first-heading (setq first-heading nil) ;; (insert "+latex:\\end\{alertblock\}")) ;; (insert "\n\n+latex: \\begin\{alertblock\}\{") (org-next-visible-heading 1) (insert "WOW :: " (s-chop-prefix "\** " heading)) (insert "\n") ;; Otherwise we impede on the auto-inserted "* footer :ignore:" ;; (insert "\}") ) ) ))) (add-hook 'org-export-before-parsing-hook 'my-headline-alteration) (remove-hook 'org-export-before-parsing-hook 'my-headline-alteration) #+END_SRC ** Appendix 𝐈𝐈𝐈 Speculative TODO-s :PROPERTIES: :CUSTOM_ID: comment-speculative-todo-s :header-args+: :tangle no :END: /Notes, not code:/ nothing below tangles. 1. Support :HTML_EXPORT_METHOD: property for trees, eg if i have an article I want exported via ox-jeopardy, then I can I can use that? 2. 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!? 3. Add a link to https://alhassy.com/next-700-module-systems/proposal/thesis-proposal.pdf and to the actual thesis. 4. Why not move the Org sources into a ~src~ directory, beside ~resources~ and ~public~? 6. TODO: The sections that have been tangled from my init: Just move that code here. That code was written with my blog in mind, and so it deserves to be here, and not in my init. 7. TODO: Look at stuff in my init and see what there is specifically serving my blog; and move that here. Can always link to AlBasmala.html. 8. Super simple validation: The number of blog posts can only increase. # ;; (length blog-posts) ⇒ 16 9. TODO: When I'm done, look around and ensure there are no 'TODO's; or if there are any, move them to my todo.org::Blog::Ideas section! 9. font for links #+html: <br> #+begin_remark Before we move on, I'd like to have heavy red font for links. # +begin_src css :tangle resources/blog-banner.css :noeval -n :tangle no HERE PLS # +end_src But this causes the table of contents to be red, which I dislike ლ(ಠ益ಠ)ლ #+end_remark 10. wrt floating toc: #+begin_remark Strange If I zoom in over 100% in my browser, the toc disappears until I zoom out. #+end_remark 11. TODO: Make the floating toc "Ξ" be aware of links... ... so that "doc:blog-new-article" renders nicely in the floating doc! 13. TODO: Find-replace all px with percentages, then ensure things look good!? 14. Column Width When blogging, I see live HTML previews whenever I save thanks to doc:my/blogging-mode. As such, I cannot have my lines being too wide. I'll settle for a modest 80 columns ---set buffer-locally by =my/blogging-mode=. Then 〔M-q〕 will format my paragraphs to wrap nicely into this 80-column width. (When I'm coding with a single open window, I usually use a comfortable 120 columns.) 15. Required loads #+begin_src emacs-lisp :exports none ;; cl-lib was published as a better (namespaced!) alternative to cl, which has a deprecation warning in Emacs27. ;; Yet some old pacakges require cl, and so the below setq silences the deprecation warning. (setq byte-compile-warnings '(cl-functions)) (require 'cl-lib) ;; to get loop instead of cl-loop, etc. (require 'shortdoc) ;; Essentially "tldr" but for Emacs Lisp! ;; (cl-defun define-short-documentation-group (&rest _)) ;; (cl-defun org-duration-to-minutes (&rest _) ) ;; (cl-defun org-id-find-id-file (&rest _)) (ignore-errors (use-package org-preview-html) (setq org-preview-html-viewer 'xwidget) ;; (xwidget-webkit-browse-url "https://github.com/adithyaov/helm-org-static-blog") (advice-add #'xwidget-webkit-browse-url :before (lambda (&rest _) (doom-modeline-mode 0))) (advice-add #'xwidget-webkit-browse-url :after (lambda (&rest _) (--map (with-current-buffer it (setq mode-line-format "%b %p L%l C%c")) (buffer-list)) )) (advice-add #'doom-modeline-mode :before (lambda (&rest _) (-let [kill-buffer-query-functions nil] (mapcar #'kill-buffer (--filter (s-starts-with? "*xwidget" (buffer-name it)) (buffer-list))) ))) (use-package org-special-block-extras) ;;(load-file "~/blog/AlBasmala.el") ) ;; Required for Github Actions; i.e., testing. ;; TODO Clean me! (defun quelpa-read-cache ()) ;; Used somewhere, but not defined. ;; See: quelpa-persistent-cache-file (setq quelpa-cache nil) ;; Eager macro-expansion failure: (void-function all-the-icons-faicon) ;; Symbol's function definition is void: all-the-icons-faicon #+end_src #+begin_src emacs-lisp :exports none ;; Error in kill-emacs-hook (org-clock-save): (void-function org-clocking-buffer) (cl-defun org-clocking-buffer (&rest _)) #+end_src **** COMMENT Arabic Setup :Possibly_already_in_AlBasmala_so_just_load_that: :PROPERTIES: :CUSTOM_ID: comment-arabic-setup :END: #+begin_src emacs-lisp (bind-key "M-x" #'execute-extended-command) (set-fontset-font "fontset-default" '(#x600 . #x6ff) "Amiri Quran Colored") ;; Makes all dots, hamza, diadiract marks colored! ;; Note: "arabic" input method just changes my English query keyboard into an Arabic keyboard ---useful if one's mastered touch typing in Arabic! ;; In contrast, the Perso-Arabic input method uses a system of transliteration: Ascii keys are phonetically mapped to Arabic letters. (bind-key* "M-SPC" (lambda () (interactive) (message (if (not current-input-method) (progn (set-input-method "farsi-transliterate-banan" t) "Perso-Arabic! Hint: M-x describe-input-method") (progn (set-input-method nil) "English!"))))) ;; Press C-q on a word to quote it with nice unicode quotes. (bind-key* "C-q" (lambda () (interactive) (insert (format ""%s"" (thing-at-point 'word))) (kill-word 1))) #+end_src ***** COMMENT [HERE TODAY] Rndm :PROPERTIES: :CUSTOM_ID: comment-here-today-rndm :END: ;; After startup, if Emacs is idle for 10 seconds, then open my work file; ;; which is a GPG file and so requires passphrase before other things can load. ;; (run-with-idle-timer 10 nil (lambda () (find-file "~/Desktop/work.org.gpg"))) ;; NOTE: Will not work when doom-modeline is enabled! ;; (xwidget-webkit-browse-url "https://www.reddit.com/r/emacs/") ;; Press C-q on a word to quote it with nice unicode quotes. (bind-key* "C-q" (lambda () (interactive) (insert (format ""%s"" (thing-at-point 'word))) (kill-word 1))) (load-file "~/blog/AlBasmala.el") #+begin_src emacs-lisp :exports code (org-deflink card (label) "Show one of 6 hardcoded phrases as a small inline image." (-let [url (pcase label ("Let's take a break" "https://alhassy.com/resources/iraqi-sign-take-a-break.png") ("Yes" "https://alhassy.com/resources/iraqi-sign-yes.png") ("No" "https://alhassy.com/resources/iraqi-sign-no.png") ("Agree" "https://alhassy.com/resources/iraqi-sign-agree.png") ("Disagree" "https://alhassy.com/resources/iraqi-sign-disagree.png") ("I have a question" "https://alhassy.com/resources/iraqi-sign-question.png"))] (format "<a href=\"%s\" class=\"tooltip\" title=\"%s\"><img src=\"%s\" height=50></a>" url label url))) #+end_src *** COMMENT Ideas :PROPERTIES: :CUSTOM_ID: comment-todo :END: # TODO: Automate the insertation of HTML Premable/postable for statics like about/AlBasmala # (maybe-clone "https://github.com/alhassy/alhassy.github.io.git" "~/blog") # TODO: Make C-u C-c C-b switch to a particular theme so all exported html stuffs # look the same ^_^ # Idea: Make the tags at the bottom be badges, alter/advice the corresponding # function # Idea: Add "last updated" date to footer? + in the index, under each article's name: - twitter link ;-) - per article via advice #+begin_export html <footer class="container"> <div class="site-footer"> <div class="copyright pull-left"> Powered by <a href="https://github.com/alhassy/emacs.d">Emacs</a> </div> <a href="https://github.com/alhassy" target="_blank" aria-label="view source code"> octicon-github </a> <div class="pull-right"> <a href="javascript:window.scrollTo(0,0)" >TOP</a> </div> </div> </footer> #+end_export + Footer should include - See Org Source; see HTML source - buy-me-a-coffee #+html: <hr> # # + search # + Org-mode unicorn as faveicon! # This, like the upcoming articles, is intended to be a living document. # The date serves to be date of the first release and the repo contains # the history of any alterations. **** COMMENT Mention alternative to using "Abstract" :PROPERTIES: :CUSTOM_ID: comment-mention-alternative-to-using-abstract :END: src: https://ogbe.net/blog/blogging_with_org.html When I write a blog post, I enclose the "preview" part of the post in #+BEGIN_PREVIEW...#+END_PREVIEW tags, which my (very simple) parser then inserts into the sitemap page. (defun my-blog-get-preview (file) "The comments in FILE have to be on their own lines, prefereably before and after paragraphs." (with-temp-buffer (insert-file-contents file) (goto-char (point-min)) (let ((beg (+ 1 (re-search-forward "^#\\+BEGIN_PREVIEW$"))) (end (progn (re-search-forward "^#\\+END_PREVIEW$") (match-beginning 0)))) (buffer-substring beg end)))) **** COMMENT Old Jekyll Setup :posterity:terrible: :PROPERTIES: :CUSTOM_ID: comment-old-jekyll-setup :header-args: :noeval :END: Write in Org-mode and generate coloured markdown for Jekyll usage # Usage ∷ Begin blog server then load AlBasmala, then edit & preview. # # (shell-command "cd ~/alhassy.github.io/ ; bundle exec jekyll serve &") ***** Server Setup :PROPERTIES: :CUSTOM_ID: server-setup :END: When drafting, it's ideal to be able to inspect the resulting web article. To do so, we may initialise the Jekyll server as follows. #+begin_src emacs-lisp :tangle no (shell-command "cd ~/alhassy.github.io/ ; bundle exec jekyll serve &") #+end_src #+RESULTS: : #<window 328 on *Org-Babel Error Output*> In order to be an Org only interface, let's remove this shell invocation from the user's view --as an Org user, they need not be forced to learn such Jekyll intricacies. #+begin_src emacs-lisp (defvar jekyll-served nil "Documents whether the blog server has begun.") (defun ensure-blog-is-serving () "Ensure that the server has begun." (unless jekyll-served (shell-command "cd ~/alhassy.github.io/ ; bundle exec jekyll serve &") (setq jekyll-served t))) #+end_src #+RESULTS: : ensure-blog-is-serving Super simple, but hides an annoying step & layer from the user. ***** ~file~ Symbols :PROPERTIES: :CUSTOM_ID: file-symbols :END: We will look at various generated files revolving around the given file, so let us generate the necessary variables that refer to such names. First off, some useful libraries. #+BEGIN_SRC emacs-lisp (require 'dash) ;; A modern list library for Emacs (require 's) ;; String processing library. #+END_SRC #+RESULTS: : s Now, let's make a function that produces our variables. This way we avoid tedious repetition of a particular pattern. #+BEGIN_SRC emacs-lisp (cl-defun make-file-extension-variables (&key prefix name extensions) " Produce symbols 'prefix.ext' whose value is the string 'name.ext', where 'ext' range over the list 'extensions'. Both 'prefix' and 'name' should be strings. I insist that the arguments be keywords, ":prefix, :name, :extensions", since I currently feel that this is more informative. All three pieces need to be there, otherwise no variables are formed. Success is signalled by the message string "new filename variables created". Moreover, these symbols are local to the current buffer; in-particular, their values cannot be altered from other buffers. " (and prefix name (dolist (ext extensions (message "new filename variables created")) (let* ((name.ext (concat name "." ext)) (symbol (intern (concat prefix "." ext)))) (set symbol name.ext) ;; (make-local-variable symbol) ;; Undesirable since I want to use these names in other buffers. )))) #+END_SRC #+RESULTS: : make-file-extension-variables :Example_of_locals_in_elisp: #+BEGIN_SRC emacs-lisp :tangle no (setq bar "noah") ;; All buffers can access this variable, with only this value as default value. (make-local-variable 'bar) ;; All future setq's only affect this buffer. (setq bar "rab") ;; As such, the following approach makes a variable local to begin with. (make-local-variable 'foo) ;; (setq foo "woah") #+END_SRC #+RESULTS: : woah :End: With that in hand, let's actually make the ~file.*~ variables. #+BEGIN_SRC emacs-lisp (setq AbsNAME (file-name-sans-extension buffer-file-name)) (setq NAME (file-name-sans-extension (buffer-name))) (make-file-extension-variables :prefix "file" :name NAME :extensions '("org" "el" "src" "tex" "pdf" "html")) #+END_SRC #+RESULTS: : new filename variables created Finally, it would be nice to know where the blog repository lives. #+BEGIN_SRC emacs-lisp (defvar blogrepo "~/alhassy.github.io/" "The path to the blog repository on a local machine.") (defvar blogrepo-posts "~/alhassy.github.io/_posts/" "The path to the blog repository's posts directory.") (defvar blogrepo-file.pdf (concat "../assets/pdfs/" file.pdf) ;; (concat "~/alhassy.github.io/assets/pdfs/" file.pdf) "The path to the blog repository where the generated PDF should live.") ;; Make these variables local to the current buffer. ;; Undesirable since I'd like to utlise these in other buffers. ;; (make-local-variable 'blogrepo) ;; (make-local-variable 'blogrepo-posts) ;; (make-local-variable 'blogrepo-file.pdf) #+END_SRC #+RESULTS: : blogrepo-file\.pdf Before we close we need Jekyll relevant names. #+begin_src emacs-lisp (defvar jekyll.name nil "The formal name of the resulting Jekyll blog article.") (defvar jekyll.name.md nil "The formal markdown of the resulting Jekyll blog article.") #+end_src #+RESULTS: : jekyll\.name\.md ***** Get Org Keywords :PROPERTIES: :CUSTOM_ID: get-org-keywords :END: We want to be able to access ~#+key: value~ pairs from the article org source as a variable ~org.key~. We also allow as input default values, since the user may not have provided values for them. #+begin_src emacs-lisp (defvar albasmala/keywords `(("title" . nil) ("date" . ,(format-time-string "%Y-%m-%d")) ("author" . nil) ("image" . nil) ("imageheight" . 142) ("imagewidth" . 142) ("categories" . nil) ("sourcefile" . ,(concat "https://raw.githubusercontent.com/alhassy/alhassy.github.io/master/content/" (buffer-name))) ("nopdf" . nil) ("nomodificationdate" . nil) ("draft" . nil)) "This list contains tuples denoting a 'property' and it's 'default' value. These are the keywords that the user of this AlBasmala setup should utilise. For example, if the user does not provide a 'date', then one is provided, for them; the default date. Note that 'sourcefile' refers to the URL to the raw master location of the blog repository by default, but it's useful for the user to set it when the file is associated with a different repoistory. The URL should begin 'https://⋯'. By default we produce a PDF and link to it from the article. If 'nopdf' is set to a non-nil value, then no PDF is generated --which may be usefull since making a pdf takes time, which may not be desirable while drafting. Likewise, we always produce the most recent modification date, unless instructed otherwise. --c.f., 'draft'. The 'draft' variable is useful since it puts the word DRAFT alongside a generated number when drafting so as to ensure you're actually re-generating the article --rather than loading a previously generated one. When drafting, no PDF is generated. Warning: The values cannot have links; e.g., embedding a link in the value of 'author' renders this script useless. ") #+end_src #+RESULTS: : albasmala/keywords For each keyword, let's uniformly produce these symbols, attempt to obtain their values, and use the defaults otherwise. #+begin_src emacs-lisp (defun make-org-variables (keywords) "For each "(key . default)" in the 'keywords' list, we produce a symbol named 'org.key' whose value is set to be the value from "#+key: value" from the current buffer. The keys may be in lower case; we upcase them before obtaining their values. If there is no value, we use the defaults in 'keywords'. " (dolist (keydef keywords (message "new org keyword variables created")) (let* ((key (car keydef)) (value (org-keyword (upcase key))) (org.key (concat "org." key)) (symbol (intern org.key))) (set symbol value) (unless value (set symbol (cdr keydef))) (put symbol 'variable-documentation "Variable generated by 'make-org-variables'") ;; (make-local-variable symbol) ;; Undesirable since I use the 'org.key' symbols ;; in the assocaited html buffers. ))) #+end_src #+RESULTS: : make-org-variables :Setting_docstrings_after_the_fact: (put FUNCTIONSYMBOL 'function-documentation VALUE) (get 'org.sourcefile 'variable-documentation) (put 'org.sourcefile 'variable-documentation "nice") (get symbol 'variable-documentation) (put 'symbol 'variable-documentation 'doc-string) :End: We know turn to actually obtaining the values of keywords as a function call. Why not just set them once? These values can be altered any time by the user, e.g., me, and as such they need to be reloaded before the post is created as a precautionary measure. E.g., the title in the org file and the title in the article may be distinct, so we allow the user this added flexibility. We invoke ~make-org-variables~ to produce variables of the form ~org.var~. #+BEGIN_SRC emacs-lisp (defun GetOrgKeyWords () "Get the #+KEYWORD values from the org-file." (make-org-variables albasmala/keywords) ;; We have these here in-case the "org.date" is altered. (setq jekyll.name (concat org.date "-" NAME)) (setq jekyll.name.md (concat org.date "-" NAME ".markdown")) ) ;; Globally set the variables ;; (GetOrgKeyWords) #+END_SRC #+RESULTS: : GetOrgKeyWords Note that these values can be manually overridden by including in your locals, for example: #+BEGIN_SRC emacs-lisp :tangle no # eval: (setq org.title "Experimenting..." ) #+END_SRC ***** MakeHeader :PROPERTIES: :CUSTOM_ID: makeheader :END: The Jekyll backend has a particular header for articles, which we produce: #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (defun MakeHeader () "Header for Jekyll backend." (setq HEADER (concat "---\nlayout: post\nname: " jekyll.name "\ntitle: " org.title "\ndate: " org.date "\nauthor: " org.author "\nimage:\n href: " org.image "\ncategories: " org.categories "\n---\n" ))) #+END_SRC ***** Article Image :PROPERTIES: :CUSTOM_ID: article-image :END: An image is included via the ~#+IMAGE:location~ --see the usages sections below. Alternative methods include. + An image can be embedded as a url, in Org-mode: #+BEGIN_SRC org :tangle no ,#+begin_export html <center> <img src="http://book.realworldhaskell.org/support/rwh-200.jpg" alt="RWH Cover" width="142" height="142" align="top"> </center> ,#+end_export #+END_SRC :One_long_line: #+BEGIN_SRC org :tangle no ,#+HTML: <center> <img src="http://book.realworldhaskell.org/support/rwh-200.jpg" alt="RWH Cover" width="142" height="142" align="top"> </center> #+END_SRC :End: + Or as an Org link: #+BEGIN_SRC org :tangle no [[file:../assets/img/rwh-200.jpg]] #+END_SRC + Or as local image via explicit html link: #+BEGIN_SRC org :tangle no ,#+begin_export html <center> <img src="../assets/img/rwh-200.jpg" alt="RWH Cover" width="142" height="142" align="top"> </center> ,#+end_export #+END_SRC :One_long_line: #+BEGIN_SRC org :tangle no ,#+HTML: <center> <img src="../assets/img/rwh-200.jpg" alt="RWH Cover" width="142" height="142" align="top"> </center> #+END_SRC :End: For now, I use the approach of inserting an HTML URL: #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (defun insert-image-and-other-formats () "Insert image location obtained from #+IMAGE org keyword, as well as top-matter." (let ((html.image.info (concat "<center> <img src=\"" org.image "\" alt=\"Musa's article image\"" " width=\"" (format "%s" org.imagewidth) "\" " "height=\"" (format "%s" org.imageheight) "\" " "align=\"top\"> </center>"))) (re-replace-in-file ;; see below (concat AbsNAME ".html") "<h1.*h1>" (lambda (x) (concat x "\n" html.image.info "\n" (make-top-matter)))))) #+END_SRC One possible extension would be to make parameters for image width and height. Perhaps I will get to doing so in time. Disclaimer: I wrote the following /before/ I learned any lisp; everything below is probably terrible. #+BEGIN_SRC emacs-lisp (defun re-replace-in-file (file regex whatDo) "Find and replace a regular expression in-place in a file. Terrible function … before I took the time to learn any Elisp! " (find-file file) (goto-char 0) (let ((altered (replace-regexp-in-string regex whatDo (buffer-string)))) (erase-buffer) (insert altered) (save-buffer) (kill-buffer))) #+END_SRC Example usage: #+BEGIN_EXAMPLE emacs-lisp ;; Within mysite.html we rewrite: <h1.*h1> ↦ <h1.*h1>\n NICE ;; I.e., we add a line break after the first heading and a new word, "NICE". (re-replace-in-file "mysite.html" "<h1.*h1>" (lambda (x) (concat x "\n NICE"))) #+END_EXAMPLE ***** PDF Generation :PROPERTIES: :CUSTOM_ID: pdf-generation :END: :Old_tangle_latex_approach: The org block header for the following has #+begin_src org :tangle no :var webArticle = (file-name-sans-extension (buffer-name)) #+end_src This allows us to use the buffer's name within the tangled LaTeX! Neato. #+NAME: headers BEGIN_SRC org :tangle headers.ltx :exports code :var webArticle = (file-name-sans-extension (buffer-name)) END_SRC That is, the string ~webArticle~ is a parameter of this source block. Later, ;; Replace webArticle with the name of the article in our headers.ltx file. (re-replace-in-file "~/alhassy.github.io/content/headers.ltx" "webArticle" (lambda (x) NAME)) :End: Finally, we weave everything together: #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el ;; Include LaTeX Org-calls, produce the PDF, then revert the file. ;; (defun prepend-for-simple-latex (&rest extras) "Prepend an Org file with a simple LaTeX preamble; perform extras before returing to source file. " (save-buffer) (copy-file file.org file.src 'overwrite) ;; Produce a checkpoint. (beginning-of-buffer) (insert (s-join "\n" `( "#+OPTIONS: toc:nil" "#+LATEX_HEADER: \\usepackage[margin=0.5in]{geometry}" "#+LATEX_HEADER: \\usepackage{fancyhdr}" "#+LATEX_HEADER: \\setlength{\\headheight}{30pt}" "#+LATEX_HEADER: \\lhead{} \\rhead{} \\cfoot{\\vspace{-3em} \\thepage} \\lfoot{} \\rfoot{}" "#+LATEX_HEADER: \\chead{\\emph{This PDF was generated \\emph{ungracefully} from a web article on" ,(concat "#+LATEX_HEADER: \\url{https://alhassy.github.io/" NAME "/}}}") "#+LATEX_HEADER: \\let\\doit=\\maketitle" "#+LATEX_HEADER: \\def\\maketitle{\\doit\\thispagestyle{fancy}}" "#+LATEX: \\pagestyle{fancy} \\tableofcontents \\newpage" "#+LATEX_HEADER: \\usepackage{color}" "#+LATEX_HEADER: \\definecolor{darkgreen}{rgb}{0.0, 0.3, 0.1}" "#+LATEX_HEADER: \\definecolor{darkblue}{rgb}{0.0, 0.1, 0.3}" "#+LATEX_HEADER: \\hypersetup{colorlinks,linkcolor=darkblue,citecolor=darkblue,urlcolor=darkgreen}" "\n" ))) ;; Using (lambda () (extras...)) makes the extras happen before the reversion below. (eval extras) ;; revert to working file (copy-file file.src file.org 'overwrite) (delete-file file.src) (toggle enable-local-variables :all (revert-buffer 'ignore-auto 'no-confirmation)) ;; A copy, rather a move, since article repo may differ from blog repo. (copy-file file.pdf ;; 'blogrepo-file.pdf' is the path relative to the blog repository; ;; this format allows us to view the PDF when the local blog server is running. ;; However, we may currently be residing in a different repository. ;; As such, we shift the cp command to move to the absolute path to the blog repo. (concat "~/alhassy.github.io" (s-chop-prefix ".." blogrepo-file.pdf)) ;; (file-truename blogrepo-file.pdf) ;; fix me 'overwrite ) ) (defun my-org-latex-export-to-pdf () "Produce a simple PDF that has wide margins and has a warning" (prepend-for-simple-latex (lambda () (org-latex-export-to-pdf))) ) #+END_SRC ***** Other Formats :PROPERTIES: :CUSTOM_ID: other-formats :END: Readers of the article may want to see the source --which may contain code or parts not rendered in the article, such as exercise solutions. # -- # or they may prefer a PDF version for printing or simply for an alternate aesthetic. #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (defun get-raw-and-commits (url) " Given a github 'url', return the associated commits history and raw textual urls, as a dotted pair. For example, url → https://github.com/⟪user⟫/⟪project⟫/blob/master/content/⟪filepath⟫ raw → https://raw.githubusercontent.com/⟪user⟫/⟪project⟫/master/content/⟪filepath⟫ commits → https://github.com/⟪user⟫/⟪project⟫/commits/master/content/⟪filepath⟫ " (let* ((github "https://github.com/") (comm (s-split "/" (s-chop-prefix github url))) ) (setf (nth 2 comm) "commits") ;; raw, then commits `( , (s-prepend "https://raw.githubusercontent.com/" (s-replace "/blob/" "/" (s-chop-prefix github url))) . ,(s-prepend github (s-join "/" comm)) ) ) ) #+END_SRC #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (defun make-html-link (url identifier) "Yield HTML string code for a link to 'url' presented as 'identifier'; if 'url' is non-nil; otherwise, yield only the text 'identifier'. " ;; (message-box url) (if url (concat "<a href=\"" url "\" target=\"_self\">" identifier "</a>") identifier ) ) #+END_SRC #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (defun make-top-matter () "This is the top-most text that appears right after the article's title. It includes viewing the source, a PDF rendition, and the most recent date of modification --unless the variables are nil. " (let* ((date (format-time-string "%Y-%m-%d")) (content "") (rawsrc (car (get-raw-and-commits org.sourcefile))) (commits (cdr (get-raw-and-commits org.sourcefile))) ) ;; Perform the loop over tuples (constraint url description). (dolist (var `( (,org.nopdf ,blogrepo-file.pdf "Read as PDF" ) (,org.nopdf nil " or " ) (nil ,rawsrc "See the source") (,org.nomodificationdate nil ,(concat " ; " (unless org.nopdf "<br>"))) (,org.nomodificationdate ,commits "Last modified") (,org.nomodificationdate nil ,(concat " on " date)) ) content) ;; Unless there are constraints, concatenate the resulting html. (unless (car var) (setq content (concat content (make-html-link (cadr var) (caddr var))))) ) ;; for debugging / drafting, (concat (when org.draft (format "<center> Draft: %s </center>" (gensym))) "<small> <center> ⟨ " content " ⟩ </center> </small>") ) ) ;; Rather than <small>, maybe utilise <font size="3">. #+END_SRC ****** COMMENT org-html-postamble-format at the end of the webpage :old_approach: :PROPERTIES: :CUSTOM_ID: comment-org-html-postamble-format-at-the-end-of-the-webpage :END: # Look at the super short doc to know how to manipulate this variable: (describe-symbol 'org-html-postamble-format) #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (setq org-html-postamble-format (let* ((nomorg (buffer-name)) (nom (file-name-sans-extension nomorg)) (src (make-html-link (concat "../content/" nomorg) "Org Source")) (nompdf (concat blogrepo "/assets/pdfs/" nom ".pdf")) (pdf (make-html-link nompdf "View me as a PDF")) ) `(("en" ,(concat "<hr> <center> Last modified on %C ; " pdf " or see the " src " ; Contact me at %e </center>")))) ) #+END_SRC To avoid having a postamble altogether we could include #+BEGIN_SRC org ,#+OPTIONS: html-postamble:nil #+END_SRC ***** ~preview-article~ -- the heart of ~AlBasmala.el~ :PROPERTIES: :CUSTOM_ID: preview-article-the-heart-of-albasmala-el :END: We make the article in stages: 0. Go to the Org source and use the native Org utitlies to produce a coloured html file. 1. Insert the article image into that html file. - We do so *before* producing the Jekyll markdown variant so that we can preview it correctly. 2. Remove some clutter from the html, yielding a markdown file. 3. Prepend the Jekyll header created using the keywords. 4. Move the markdown file to the ~_posts~ directory and show the html file in a browser. :Nope: We use ~toggle~, a personal function from my ~init~, that toggles a variables value till the end of its form. We use it below to disable all Emacs buffer local variables, do some work, then re-enable them afterwards. Such variables generally require a query since they could be dangerous, like erasing the disk, so we disable them temporarily. :End: #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (local-set-key (kbd "<f7>") 'preview-article) (cl-defun preview-article (&key (browser nil) (draft nil)) "Create and preview a the html form of the content. A non-nil value for "org.nopdf" short-circuits the generation of a PDF, thereby yielding a possibly faster execution. A non-nil value for ":browser" opens the article using the default browser. This may be undesirable, since it may open many tabs in your brower. The 'draft' keyword option is here in case we want to override whatever the local '#+DRAFT' value may be. " (interactive) (save-buffer) (ensure-blog-is-serving) ;; Remove any existing html, in case we fail to generate it ;; we do not want to render an out of date version. (shell-command (concat "rm ~/alhassy.github.io/_posts/" jekyll.name.md)) (setq enable-local-variables nil) (setq enable-local-eval nil) ;; compile coloured html (find-file file.org) (GetOrgKeyWords) (when draft (setq org.draft draft)) (org-html-export-to-html) ;; Insert image, duh. (insert-image-and-other-formats) ;; Discard first 3 lines, (note the 1-indexing), since they don't look very nice ;; in the resulting markdown file when rendered on the Jekyll site. (shell-command (concat "tail -n +4 <" file.html " >" jekyll.name.md)) ;; Preprend file with a header. (find-file jekyll.name.md) (beginning-of-buffer) (MakeHeader) (insert HEADER) (save-buffer) (kill-buffer jekyll.name.md) ;; Move it to posts directory. (shell-command (concat "mv " jekyll.name.md " " blogrepo-posts)) ;; ;; Uncomment for debugging. ;; ;; (find-file (concat "~/alhassy.github.io/_posts/" jekyll.name.md)) ;; no pdf generation in draft mode (unless (or org.draft org.nopdf) (my-org-latex-export-to-pdf)) ;; Preview locally in browser. (when browser (let* ((buf (concat "*AlBasmala*" NAME "*"))) (toggle kill-buffer-query-functions nil (ignore-errors (kill-buffer buf))) (async-shell-command (concat "open http://localhost:4000/" NAME "/") buf) ) ) (message "Article has been opened in your browser.") (setq enable-local-variables t) (setq enable-local-eval t) ) #+END_SRC ***** COMMENT Version control :Deprecated:Before_magit_time: :PROPERTIES: :CUSTOM_ID: comment-version-control :END: A simple version control mechanism; will likely switch to ~magit~ in the future. #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (global-set-key (kbd "<f8>") 'commit) (defun commit () "Commit changes to git in the form: "ChangedFile: CommitMessage"." (interactive) ;; In-case the article was updated but we forgot to produce new generated files. (preview-article) (shell-command "rm *.html") ;; remove noise (let ((msg (read-string (format "Commit message for %s: " NAME)))) ; (shell-command (format "git add ../_posts/%s ../content/%s %s %s" jekyll.name.md file.org blogrepo-file.pdf file.el)) ; (shell-command (format "git commit ../_posts/%s ../content/%s %s %s -m \"%s: %s\"" jekyll.name.md file.org blogrepo-file.pdf file.el NAME msg)) ;; "git add commitables" (shell-command (s-join " " (cons "git add" commitables))) ;; "git commit commitables -m NAME: message" ;; Note that the commit message needs to be in quotes. (shell-command (s-join " " (append (cons "git commit" commitables) (list (format "-m \"%s: %s\"" NAME msg))))) ) ) #+END_SRC ***** Publish :PROPERTIES: :CUSTOM_ID: publish :END: #+BEGIN_SRC emacs-lisp :tangle AlBasmala.el (defun publish () "Send material to github pages." (interactive) (message (format "Publishing article: %s " NAME)) (shell-command "rm *.html") ;; remove noise (eshell) (with-current-buffer "*eshell*" (eshell-return-to-prompt) (insert (concat "cd ~/alhassy.github.io/_posts/" " ; " (format "git add %s %s" jekyll.name.md blogrepo-file.pdf)) " ; " (format "git commit %s %s -m \"%s: %s\"" jekyll.name.md blogrepo-file.pdf NAME "Article updated.") " ; " "git push") (switch-to-buffer "*eshell*") (eshell-send-input) ) ) #+END_SRC # Remember it takes 10 seconds for the live github page to actually change! ***** Usage :PROPERTIES: :CUSTOM_ID: usage :END: # Within src blocks containing org, you need to escape org heading, the `*`, delimiters with a comma. # E.g.: ,* My heading #+BEGIN_EXPORT html <table style="width:100%"> <tr> #+END_EXPORT #+HTML: <td> The example source, #+HTML: <small> # TODO. ? +INCLUDE: "template.org" src org #+HTML: </small> </td> #+HTML: <td> Results in, <br> <br> <br> #+BEGIN_EXPORT html <iframe src="../assets/demoing_template.html" style="width:100%" height="487"> alternative content for browsers which do not support iframe. </iframe> #+END_EXPORT #+HTML: </td> #+BEGIN_EXPORT html </tr> </table> #+END_EXPORT #+latex: In the LaTeX format, this content is not supported. ***** footer :PROPERTIES: :CUSTOM_ID: footer :END: NOTE: It takes about 20secs ~ 1min for the changes to be live on github pages. *** COMMENT Using org-static-blog :PROPERTIES: :CUSTOM_ID: https-github-com-bastibe-org-static-blog-org-static-block :END: Let's use org-static-block to make our blog. Why? - It's a Lisp program smaller than 900 lines, its source is easy to read and understand, and, most importantly, it was super easy to get started using it using the given example.