Home / Notes /

Blog in Org Mode, Revisited

Table of Contents

I have an earlier post about the same subject. Since then, a lot of things have changed, so I decide to revisit the topic and talk about my improved work flow. This post is a super set of the earlier one, so there is no need to check that out.

The source file of my blog at the time writing can be found here.

1 Why no frameworks?

Personally, I don't like those static site generates, e.g. Hexo, Pelican, Hugo, Jekyll. Each one of them requires you to learn the framework and set it up correctly. It feels like too much work and complexity for a simple static site.

On the other hand, when directly exporting HTML files from Org files, you have the full control of the whole process. And customizing is often trivial.

I'll demonstrate how I build my blog with Org Mode and CSS, and let you decide whether to do the same.

2 Style

I want my blog to be in the old school style like 90's hypertext pages. Some example includes Emacs Lisp Reference Manual, Style Guide for online hypertext, CS166 of Stanford. The simplicity, elegance and plain coolness (what's that?) really attract me, and I hope you, too.

Don't get me wrong, modern web pages like apple.com are beautiful, too. But they are complicated and hard to maintain. I don't feel like spending all the time to make a beautiful animated site while the time could be used to generate better contents. Plus I prefer the "old style" anyway.

3 Implementation

3.1 File structure

The file structure of my blog, root directory is https://archive.casouri.co.uk/note/

  • index.org: the Org file for the index page
  • index.html: the exported index page
  • setup.org: my setup file (kind of like template) for Org Mode export
  • style.css: the style sheet for all the pages
  • script.js: the script file for all the pages. Currently I don't have anything in there.
  • year(e.g. 2918)
    • post: each post is in a separate directory
      • index.org: the Org file
      • index.html: the exported HTML file
      • other static files used in the page

3.3 Template (sort of)

This is my template:

#+OPTIONS: html-style:nil
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="../../style.css"/>
#+HTML_HEAD_EXTRA: <script type="text/javascript" src="../../script.js"></script>
#+HTML_HEAD_EXTRA: <link rel="icon" type="image/png" href="../../../favicon.png">
#+HTML_LINK_UP: ../../index.html
#+HTML_LINK_HOME: ../../index.html
#+OPTIONS: toc:2

It is called setup file in Org Mode. In index.org file of each post, there is a line #+SETUPFILE: ../../setup.org. When Org exports the file, it first loads the setup file (setup.org), and environment set by that file will be used when exporting the post. You can think of it as adding these lines to every Org file before exporting.

The purpose of each line:

#+OPTIONS: html-style:nil

ꜛ disable the default styling that Org HTML exporter uses. I style my blog in my own CSS file.

#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="../../style.css"/>

ꜛ Link to my style sheet.

#+HTML_HEAD_EXTRA: <script type="text/javascript" src="../../script.js"></script>

ꜛ Link to my script file.

#+HTML_HEAD_EXTRA: <link rel="icon" type="image/png" href="../../../favicon.png">

ꜛ Link to my favicon.

#+HTML_LINK_UP: ../../index.html
#+HTML_LINK_HOME: ../../index.html

ꜛ Add UP and HOME link to head line. See more below.

#+OPTIONS: toc:2

ꜛ Collect down to the second level header for TOC.

3.4 TOC

On narrow screens, the table of content will simply be on top of the body. On wider screens, I made it to float on the right. If you are reading this post on a PC, you can probably see it.

It is achieved by this CSS snippet:

@media screen and (min-width: 800px) {
    /* floating TOC */
    #table-of-contents  {
        font-size: 12pt;
        bottom:0;
        position:fixed;
        overflow-y:scroll;
        overflow-x:hidden;
        top: 5%;
        right: 2%;
        width: 20%;
    }
    /* centered content */
    body {
        margin-left: 10%;
        margin-right: 30%;
        /* this way floating TOC wouldn't touch content */
        width: 58%;
    }
}

You can see that on wider screens, the content only occupies 60% (actually 58%) of the width of the screen.

For the TOC, overflow-y:scroll; makes TOC scroll able in case TOC is height is larger than the screen height.

3.5 Head line

Update <2018-11-18 Sun>:

I made all pages to have the modified headline. See below.

The head line is the strip on the very top of each page. Specifically the line UP | HOME on posts and UP | HOME RSS | Source | License on the index page.

The normal behavior of it is UP | HOME. Org HTML exporter adds this head line when you have

#+HTML_LINK_UP: path-up-a-level
#+HTML_LINK_HOME: path-to-home

in your setup. As you have already seen, I have these configured in my setup file.

For the index page, however, I hacked it a little bit. In index.org of the index page, I have this snippet in the end of the file:

# Local Variables:
# org-html-home/up-format: "<div id=\"org-div-home-and-up-index-page\"> <div> <a accesskey=\"h\" href=\"%s\"> UP </a> | <a accesskey=\"H\" href=\"%s\"> HOME </a> </div> <div> <a href=\"./index.xml\"> RSS </a> | <a href=\"https://github.com/casouri/casouri.github.io\"> Source </a> | <a href=\"https://creativecommons.org/licenses/by-sa/4.0/\"> License </a> </div> </div>"
# End:

That is a file local variable, it sets org-html-home/up-format to

<div id="org-div-home-and-up-index-page">
  <div>
    <a accesskey="h" href="../home/index.html"> UP </a>
    |
    <a accesskey="H" href="../home/index.html"> HOME </a>
  </div>
  <div>
    <a href="./index.xml"> RSS </a>
    |
    <a href="https://github.com/casouri/casouri.github.io"> Source </a>
    |
    <a href="https://creativecommons.org/licenses/by-sa/4.0/"> License </a>
  </div>
</div>

and in effect, injects RSS | Source | License part into the format.

To make the two part align with either side, I set the style of org-div-home-and-up-index-page as

div#org-div-home-and-up-index-page {
    /* headline */
    display: flex;
    justify-content: space-between;
}

See here for more on CSS flex box.

3.6 RSS

RSS feed is an essential part of a blog. However, Org Mode doesn't make it easy to add one for my blog.

Some references that helped me along the way:

I use a modified ox-rss.el to generate RSS file. As its name suggests, it is a contrib package for Org Mode, so you need to download it first.

3.6.1 What does ox-rss.el do

ox-rss.el exports each first-level header in the current file to an entry of RSS file. The description of each entry is whatever inside the header.

It adds ID, PUBDATE to each header if none exists. ID is a pointer to the header so it can build a link that points to the header in RSS file. This is not useful for me because each headline in the index page is really just a link to my post with a short description. The actual content is not there. For that matter, I use RSS_PERMALINK to set the link manually. PUBDATE is the publication date of the post.

So a header will look like this (the backslash on the first line is for escaping asterisk after it):

\* [[./2018/this-is-my-post/index.html][This Is My Post]] :COOL:
:PROPERTIES:
  :ID: some-id-afnoef73r3rb3rv3l
  :PUBDATE: <2018-11-16 Fri>
  :RSS_PERMALINK: https://archive.casouri.co.uk/note/2018/this-is-my-post/index.html
:END:
This is my post. It's cool.

Some issues:

  1. That's a lot of typing
  2. I'm repeating the path to my post and the root url of my blog, that's not DRY.
  3. I have DATE set in each post's index.org. And I don't feel like manually typing them here. That isn't DRY, either.

On top of that, ox-rss does something not so good with RSS_PERMALINK: it prefixes my link with path of UP or HOME if they exists. In my case they do, and the final url becomes ../index.htmlhttps://archive.casouri.co.uk/note/path/to/my/post/index.html.

3.6.2 My modification

I don't want to modify the default behavior of ox-rss.el, so I added two properties — RSS_BASE_URL and RSS_RELATIVE_LINK. And modified the source of ox-rss.el:

;; In `org-rss-headline'
(let (...
      (hl-rel (org-element-property :RSS_RELATIVE_LINK headline))
      (url-base (org-element-property :RSS_URL_BASE headline)))
...
(publink
 (or (and hl-rel url-base (concat url-base hl-rel))
     (and hl-perm (concat (or hl-home hl-pdir) hl-perm))
     (concat
      (or hl-home hl-pdir)
      (file-name-nondirectory
       (file-name-sans-extension
        (plist-get info :input-file))) "." htmlext "#" anchor))) htmlext "#" anchor))))

And the header would look like

\* [[./2018/this-is-my-post/index.html][This Is My Post]] :COOL:
  :PROPERTIES:
  :ID: some-id-afnoef73r3rb3rv3l
  :PUBDATE: <2018-11-16 Fri>
  :RSS_BASE_URL: https://archive.casouri.co.uk/note/
  :RSS_RELATIVE_LINK: 2018/this-is-my-post/index.html
  :END:
This is my post. It's cool.

3.6.3 Macro make it DRY

It works now, but the issues 1, 2, 3 are still not resolved. For that, we can use a macro to do the typing for us.

With macro post, above text shrinks to

{{{post(This Is My Post,2018/this-is-my-post/,:COOL:)}}}
This is my post. It's cool.

I have a command1 to type even that for me, so all I need is type the title: "This Is My Post".

The macro2 is defined as:

#+MACRO: post (eval (format "* [[./$2index.html][$1]] $3\n  :PROPERTIES:\n  :RSS_RELATIVE_LINK: $2\n  :RSS_URL_BASE: https://archive.casouri.co.uk/note/\n  :PUBDATE: %s\n  :END:" (let ((buffer (find-file-noselect "$2index.org")) date) (setq date (with-current-buffer buffer (plist-get (car (cdr (car (plist-get (org-export-get-environment) :date)))) :raw-value))) (kill-buffer buffer) date)))

I know looks like heap of crap, here is the code prettied: $1 is the first argument — the title, $2 is the path, $3 are the tags.

(eval
 (format "* [[./$2index.html][$1]] $3
  :PROPERTIES:
  :RSS_RELATIVE_LINK: $2
  :RSS_URL_BASE: https://archive.casouri.co.uk/note/
  :PUBDATE: %s
  :END:"
         (let ((buffer (find-file "$2index.org"))
               date)
           (setq date (with-current-buffer
                          (plist-get
                           (car
                            (cdr
                             (car
                              (plist-get
                               (org-export-get-environment)
                               :date))))
                           :raw-value)))
           (kill-buffer buffer)
           date)))

The (with-current-buffer ...) part opens the post's index.org file and extracts the date out.

3.7 Tag filters for index page

(Updated on <2018-11-18 Sun>)

I have tags on the right of each header on the index page. You can't click them, though.

Normally when a blog has tags, you can click one, and it brings you to a page listing all the posts with that tag. I didn't go along with that approach, but make a filter button for each tag. Selecting and de-selecting each tag will hide and show posts with that particular tag on the index page. It's pretty cool.

Initially I made the buttons to have three states: include, noselect, and exclude. include and noselect are normal selecting and de=selecting. excluede means “don't show posts with this tag, not even when the post has a tag that is in include state”.

I figure it would probably confuse people and it's use case is pretty limited; so I removed it.

The idea is, each time a button is clicked, toggle it's state (implemented with class attribute) and add/remove it from "included tags list" (initial every tag is in the list). Then scan through the DOM and display/hide according to "included tags list".

I put the HTML, CSS and JavaScript in 3.11.

3.8 Publish

(Updated on <2018-11-18 Sun>)

I write this publish function so I don't need to export by hand. The function only export when org file is newer than html file.

Another benefit of publish function is that I can add custom environment variables before export. I set org-html-home/up-format and org-html-postamble-format to custom values.

(defvar moon-org-html-postamble-format
  '(("en" "<p class=\"author\">Written by %a <%e></p>
<p class=\"first-publish\">First Published on %d</p>
<p class-\"last-modified\">Last modified on %C</p>")))

(defvar moon-org-html-home/up-format
  "<div id=\"org-div-home-and-up-index-page\">
<div>
<a accesskey=\"h\" href=\"%s\"> UP </a> |
<a accesskey=\"H\" href=\"%s\"> HOME </a>
</div>
<div>
<a href=\"./index.xml\"> RSS </a> |
<a href=\"https://github.com/casouri/casouri.github.io\"> Source </a> |
<a href=\"https://creativecommons.org/licenses/by-sa/4.0/\"> License </a>
</div>
</div>")

(defvar moon-publish-root-dir "~/p/casouri/note/")

(require 'f)

(defun moon/publish (&optional force)
  "Publish my blog.
If FORCE is non-nil, only export when org file is newer than html file."
  (interactive)
  (dolist (dir (f-directories moon-publish-root-dir))
    (dolist (post-dir (f-directories dir))
      (moon-html-export post-dir force)))
  (require 'ox-rss)
  (moon-html-export moon-publish-root-dir force)
  (let ((buffer (find-file (expand-file-name "index.org" moon-publish-root-dir))))
    (with-current-buffer buffer
      (org-rss-export-to-rss))
    (kill-buffer buffer)))

(defun moon-html-export (dir &optional force)
  "Export index.org to index.html in DIR is the latter is older.
If FORCE is non-nil, only export when org file is newer than html file."
  (moon-load-theme 'doom-one-light)
  (let ((org-html-postamble-format moon-org-html-postamble-format)
        (org-html-postamble t)
        (org-html-home/up-format moon-org-html-home/up-format)
        (org-file (expand-file-name "index.org" dir))
        (html-file (expand-file-name "index.html" dir)))
    (when (or force (file-newer-than-file-p org-file html-file))
      (let ((buffer (find-file org-file)))
        (with-current-buffer buffer
          (org-html-export-to-html))
        (kill-buffer))))
  (moon-load-theme 'doom-cyberpunk))

3.9 Other CSS tricks

3.9.1 Code block

code, .example, .src {
    padding: 3px;
    background-color: #F4F6F6;
    font-size: 12pt;
    overflow-x: scroll;
}

3.9.2 Tags

span.tag span {
    /* headline tags */
    font-size: 12pt;
    border-width: 2px;
    border-style: solid;
}
code {
    white-space: nowrap;
}

3.9.3 Footnote

.footdef {
    /* make footnote number and content to be on th same line */
    display: flex;
}

3.9.4 Image size

I limit the image size to 600px width:

img {
    max-width: 600px;
}

3.10 Misc

3.10.1 Syntax highlight

Syntax highlight takes the current font-lock color for the exported HTML. So switch to a light theme for reasonable syntax colors.

3.11 Filter code

<div id="taglist">
<p onclick="toggleAll()" id="tagAll">All</p>
<p onclick="toggleTag(this)">Emacs</p>
<p onclick="toggleTag(this)">Org_Mode</p>
<p onclick="toggleTag(this)">Web</p>
<p onclick="toggleTag(this)">Programming</p>
<p onclick="toggleTag(this)">Network</p>
<p onclick="toggleTag(this)">Music</p>
<p onclick="toggleTag(this)">Design</p>
<p onclick="toggleTag(this)">Anime</p>
<p onclick="toggleTag(this)">Hacker</p>
</div>
/* desktop, tablet landscape */
@media screen and (min-width: 1025px) {
    div#taglist  {
        position: fixed;
        overflow-y: scroll;
        overflow-x: wrap;
        top: 40pt;
        left: 2%;
        /* width: 20%; */
    }
    div#taglist p {
        /* make cursor hand on hover */
        cursor: pointer;
        margin-top: 20pt;
        border-width: 2px;
        border-style: solid;
        padding-left: 1em;
        padding-right: 1em;
        text-align: right;
    }
    div#taglist p:hover {
        background-color: black !important;
        color: white !important;
    }

    div#taglist p.noselect {
        color: gray;
        border-color: gray;
    }

    div#taglist p.include {
        color: black;
        border-color:black
    }

    div#taglist p.exclude {
        text-decoration: line-through;
    }
}

I commented out the exclude part, if you like it, you can put it back in.

function myremove(lst, elt) {
  var index = lst.indexOf(elt)
  if (index > -1) {
    lst.splice(index, 1)
  }
}

// tag filtering


window.onload = setupTagList

var excludeTagList = []
var includeTagList = []
var allTagList = []

function setupTagList() {
  for (var tag of document.getElementById('taglist').children) {
    tag.className = 'include'
    includeTagList.push(tag.innerHTML)
    allTagList.push(tag)
  }
}

function toggleAll() {
  toggleTag(document.getElementById('tagAll'))
  for (tag of allTagList) {
    while (tag.className !== tagAll.className) {
      toggleTag(tag)
    }
  }
}

function toggleTag(tag) {
  switch (tag.className) {
    case 'include':
      var nextState = 'noselect'
      myremove(includeTagList, tag.innerHTML)
      break
    case 'noselect':
      // var nextState = 'exclude'
    // excludeTagList.push(tag.innerHTML)
    var nextState = 'include'
      includeTagList.push(tag.innerHTML)
      break
    // case 'exclude':
    //   var nextState = 'include'
    //   myremove(excludeTagList, tag.innerHTML)
    //   includeTagList.push(tag.innerHTML)
    //   break
  }
  tag.className = nextState
  filterHeaders()
}

function filterHeaders() {
  for (var header of document.getElementById('content').children) {
    if (header.className === "outline-2") {
      for (var tag of header.getElementsByClassName('tag')[0].children) {
        if (includeTagList.includes(tag.innerHTML)) {
          header.style.display = 'block'
          break
        } else {
          header.style.display = 'none'
        }
      }
      // exclude list overrides include list
      for (var tag of header.getElementsByClassName('tag')[0].children) {
        if (excludeTagList.includes(header.tagName)) {
          header.style.display = 'none'
        }
      }
    }
  }
}

Footnotes:

1

The command also creates files and folders for me and types the necessary options for me. Here is the code:

(defun moon/new-blog (title)
  "Make a new blog post with TITLE."
  (interactive "M")
  (let* ((year (shell-command-to-string "echo -n $(date +%Y)"))
         (dir-file-name (downcase (replace-regexp-in-string " " "-" title)))
         (dir-path (concat (format  "~/p/casouri/note/%s/"
                                    year)
                           dir-file-name))
         (file-path (concat dir-path
                            "/index.org")))
    (mkdir dir-path)
    (find-file file-path)
    (insert (format "#+SETUPFILE: ../../setup.org
#+TITLE: %s
#+DATE:
"
                    title))
    (kill-new (format "{{{post(%s/%s/,%s)}}}"
                      title
                      year
                      dir-file-name))
    (save-buffer)
    (find-file "~/p/casouri/note/index.org")))
2

The manual doesn't mention that you can use (eval) inside macros. Note that if you use (eval), the whole macro definition has to be in (eval):

#+MACRO naive-macro something (eval "like this") doesn't work.

That will just expand to

something (eval "like this") doesn't work.

On the other hand,

#+MACRO reasonable-macro (eval "Something like this works.")

Written by Yuan Fu

First Published in 2018-11-16 Fri 00:00

Last modified in 2020-08-20 Thu 13:12

Send your comment to archive.casouri.cat@gmail.com