Categories: 11ty

RSS - Atom - Subscribe via email

Adding subheadings and sketches to my blog page navigation

Posted: - Modified: | 11ty, blogging

[2025-04-04 Fri]: Fixed link to onThisPage.cjs. Thanks to John Rakestraw for pointing it out!

Assumed audience:

  • My future self, when I'm trying to figure out where to change things if I want to implement something similar
  • People who like tweaking their blog's CSS, especially if they also use Org Mode or 11ty

Headings help us make sense of longer blog posts. Heading links are like signposts letting you know what's ahead and where you can take a shortcut to get to what you're interested in.

Headings are useful for me too. Sometimes I browse my blog and come across things I've completely forgotten writing about, so the headings can help me remember without having to reread long posts. If I use headings more often, I might be able to work with bigger chunks of thoughts. If I can work with bigger chunks of thoughts, then maybe I can think about more things that are hard to fit within the limits of my working memory. Making headings more navigable also means I might not have to worry about the tangents I go on and the number of different thoughts I try to smoosh together, if people can jump straight to the parts that sound relevant to them.

I particularly like the way Karthik uses a sticky table of contents for long blog posts like The Emacs Window Management Almanac | Karthinks. I also like the way the Read the Docs Sphinx Theme displays a nested table of contents on the left side on wide screens. I use org-html-themes to export Org Mode files with that theme when I want to be fancy, like my Emacs configuration (usually works, although sometimes my config is broken).

The last time I tinkered with my webpage margins, I put my "On this page" list on the left side and the blog post headings (if any) on the right side, mostly because it was easy to do. I just changed the margin and float attributes of the element with the subheadings. I'd like to clear up more space for potential sidenotes or doodles, though. This time, I experimented with nesting the blog navigation inside the "On this page" navigation on the left side.

2025-04-02_14-04-30.png
Figure 1: A screenshot of my blog showing the nested links

Here's how I did it:

Org Mode: where I start writing

I use Org Mode's table of contents directive to include a table of contents in blog posts. I wrap it inside a sticky-toc block to indicate when I want it to be part of the sticky table of contents on my blog. In Org Mode, the syntax looks like this:

#+begin_sticky-toc
#+TOC: headlines 2 local
#+end_sticky-toc

I put it in a yasnippet so that I don't have to remember it. I just type tocs (for TOC, sticky) and then press TAB to complete it.

11ty static site generator: on this page

After exporting individual HTML files from Org Mode, I turn them into my blog using the 11ty static site generator. To simplify my archive pages, I have an onThisPage shortcode which lists the posts on that page. I changed it to include the sticky-toc contents from the items' templateContent attributes.

const { JSDOM } = require('jsdom');

module.exports = function (eleventyConfig) {
  function formatPostLine(item, index) {
    let subtoc = '';
    if (item.templateContent?.match(/sticky-toc/)) {
      const doc = new JSDOM(item.templateContent).window.document;
      const sub = doc.querySelector('.sticky-toc ul, .sticky-toc div, .sticky-toc-after-scrolling div');
      if (sub) {
        if (sub.querySelector('.panzoom')) {
          console.log('remove panzoom');
          sub.querySelector('.panzoom').classList.remove('panzoom');  // don't do panzoom for now
        }
        sub.classList.remove('panzoom');
        subtoc = sub.outerHTML;
      }
    }
    return `<li><a class="toc-link" data-index="index${index}" href="${item.url}">${item.data.title}</a>${subtoc}</li>`;
  }
  eleventyConfig.addShortcode('onThisPage', function (list) {
    return `<nav class="on-this-page">
On this page:
<ul>
${list.map(formatPostLine).join("\n")}
</ul>
</nav>`;
  });
};

And then there's a bunch of CSS in assets/css/style.css:

CSS
/* tables of contents */
.on-this-page > ul > li > ul, .on-this-page > ul > li > div { display: none }

@media only screen and (width >= 95em) {
    html, body { overflow-x: unset; }

    .sticky-toc, .sticky-left, .sticky-right {
        font-size: var(--fs-sm);
        width: calc((100vw - var(--body-max-width) - 5rem)/2);
        position: sticky;
        max-height: calc(100vh - 2rem);
        overflow-y: auto;
        scroll-behavior: smooth;
        background: var(--modus-bg-main);
        top: 0;
        padding: 1rem;
    }

    article .sticky-toc {
        display: none
    }

    .single-post article .sticky-toc {
        display: block;
    }

    .sticky-toc, .sticky-left, .single-post article .sticky-toc {
        margin-left: calc((-100vw + var(--body-max-width))/2);
        float: left;
    }

    .sticky-right {
        margin-right: calc((-100vw + var(--body-max-width))/2);
        float: right;
    }

    /* Hide the TOCs for non-active posts, but only if JS is enabled */
    .js .on-this-page > ul > li > ul, .js .on-this-page > ul > li > div { display: none }
    .on-this-page > ul > li.post-active > ul, .on-this-page > ul > li.post-active > div { display: block }

    .active { background-color: var(--modus-bg-tab-bar) }

    .sticky-toc svg .active rect {
        fill: var(--modus-bg-tab-bar) !important;
        fill-opacity: 1 !important;
        mix-blend-mode: darken;
        stroke-dash-array: unset !important;
        stroke-width: 4px;
    }

    .link-to-nonsticky-toc {
        display: none
    }
}


I also have some Javascript to highlight the active post and show the subheadings for it in assets/js/misc.js.

Javascript
/* Table of contents */

function stickyTocAfterScrolling() {
  const elements = document.querySelectorAll('.single-post .sticky-toc-after-scrolling');
  let lastScroll = window.scrollY;

  elements.forEach(element => {
    const clone = element.cloneNode(true);
    clone.setAttribute('class', 'sticky-toc');
    clone.querySelector('.panzoom')?.classList.remove('panzoom');
    element.parentNode.insertBefore(clone, element.nextSibling);
  });

  const observer = new IntersectionObserver(
    (entries) => {
      const currentScroll = window.scrollY;
      const scrollingDown = currentScroll > lastScroll;
      lastScroll = currentScroll;

      entries.forEach(entry => {
        const element = entry.target;
        const clone = cloneMap.get(element);

        if (!entry.isIntersecting && scrollingDown) {
          clone.setAttribute('class', 'sticky-toc');
          clone.style.display = 'block';
        } else if (entry.isIntersecting && !scrollingDown) {
          element.style.visibility = 'visible';
          clone.style.display = 'none';
        }
      });
    },
    {
      root: null,
      threshold: 0,
      rootMargin: '-10px 0px 0px 0px'
    }
  );

  elements.forEach(element => {
    observer.observe(element);
  });

  window.addEventListener('resize', () => {
    elements.forEach(element => {
      const clone = cloneMap.get(element);
      if (clone.style.display != 'none') {
        // reset didn't seem to work
        svgPanZoom(clone.querySelector('svg')).destroy();
        addPanZoomToElement(clone.querySelector('svg'));
      }
    });
  }, { passive: true });
}

stickyTocAfterScrolling();

function scrollToActiveTocLink() {
  const activeLink = document.querySelector('.sticky-toc .active');
  const tocContainer = document.querySelector('.sticky-toc');
  if (!activeLink || !tocContainer) return;
  const tocRect = tocContainer.getBoundingClientRect();
  const linkRect = activeLink.getBoundingClientRect();
  if (linkRect.top < tocRect.top || linkRect.bottom > tocRect.bottom) {
    const scrollPosition = linkRect.top + tocContainer.scrollTop -
                          (tocRect.height / 2) + (linkRect.height / 2);
    tocContainer.scrollTo({
      top: scrollPosition,
      behavior: 'smooth'
    });
  }
}
function getVisibleArticle() {
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
  return [...document.querySelectorAll('article')].find((article) => {
    const rect = article.getBoundingClientRect();
    const visibleTop = Math.max(0, rect.top);
    const visibleBottom = Math.min(viewportHeight, rect.bottom);
    const visibleHeight = Math.max(0, visibleBottom - visibleTop);
    return visibleHeight > 0; // find the first visible one
  });
}

function handleActiveTOCLink() {
  const updateActive = function(links, active) {
    const activeFragment = active.includes('#') ?
          active.substring(active.indexOf('#')) : '';
    links.forEach(link => {
      const href = link.getAttribute('href');
      if (href.includes(window.location.origin)) {
        link.classList.toggle('active', href == active)
      } else if (href.startsWith('#')) {
        link.classList.toggle('active', href == activeFragment);
      }
    });
  };
  const posts = document.querySelectorAll('.post');
  const tocLinks = document.querySelectorAll('.on-this-page .toc-link');
  const options = {
    root: null,
    rootMargin: '-20% 0px -70% 0px',
    threshold: 0
  };
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const id = entry.target.id;
        const link = document.querySelector(`.toc-link[data-index="${id}"]`);
        document.querySelectorAll('.sticky-toc .active').forEach((o) => o.classList.remove('active'));
        document.querySelectorAll('.post-active').forEach((o) => o.classList.remove('post-active'));
        if (link) {
          link.classList.add('active');
          const item = link.closest('li');
          item.classList.add('post-active');
        }
      }
    });
    scrollToActiveTocLink();
  }, options);
  posts.forEach((post) => { observer.observe(post); });

  const stickyTocLinks = document.querySelectorAll('article .sticky-toc a, .on-this-page a');
  const postTocObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const id = entry.target.id;
        const url = window.location.origin + window.location.pathname + '#' + id.replace(/^outline-container-/, '');
        updateActive(stickyTocLinks, url);
      }
    });
    scrollToActiveTocLink();
  }, options);

  document.querySelectorAll('article .sticky-toc, article .sticky-toc-after-scrolling').forEach((toc) => {
    const post = toc.closest('article');
    if (post) {
      post.querySelectorAll('.outline-2, .outline-3').forEach((section) => { postTocObserver.observe(section) });
    }
  });
  const visible = getVisibleArticle();
  const id = visible?.id;
  if (id) {
    const activeLink = document.querySelector(`.toc-link[data-index="${id}"]`);
    if (activeLink) {
      activeLink.classList.add('active');
      activeLink.closest('li').classList.add('post-active');
      scrollToActiveTocLink();
    }
  }
}
handleActiveTOCLink();

I can use a sketch as a map, too

I sometimes want to use sketchnotes as overviews, especially if I've added hyperlinks to them. I used to make the images show up on the right side, but now I want them to show up in the left-side navigation instead. Also, I wanted any links to headings to automatically get recoloured as I scroll to that heading.

2025-04-02-scrolling-svg.gif
Figure 2: Animated GIF showing how the SVG highlights change as you scroll down

I added a special case to the handleActiveTOCLink function to handle anchor hyperlinks (just #anchor) in the SVG. It probably makes sense to make those absolute URLs, which means slightly changing my workflows for hyperlinking SVGs and writing about sketches.

So on both the category page (ex: the Hyperlinking SVGs entry in category - drawing, which might have moved off the first page of results if you're reading this far in the future) and the single-post page (ex: Hyperlinking SVGs), there's a full-sized version of the image in the main blog post, and then a small copy of it in the margin on the left. The sidebar copy is probably too small to read, but it might be enough to get a sense of spatial relationships, and the links also have title attributes that are displayed as tooltips when you hover.

2025-04-03_14-28-48.png
Figure 3: Screenshot of small image in sidebar on the single post page

I use Javascript to duplicate the image and make a small, sticky version because I haven't quite figured out how to properly make it sticky when off-screen with just CSS. Even my JS feels a little tangled. Maybe this would be a good excuse to learn about web components; someone's probably figured out something polished.

I'm curious about using more drawings to anchor my thinking and structure my blog posts.

Progressive enhancement

Some people read my blog using EWW (the Emacs Web Wowser, of course), so I want my blog to be reasonable even without CSS and JS.

A number of people read my blog without Javascript enabled. I installed the Firefox extension Script Switch so that I can test my blog with and without Javascript whenever I remember.

I sometimes look up my blog posts on my phone and there's no space for any of this fanciness there, so it'll only kick in on large screens. My CSS file is littered with various breakpoints I've cargo-culted over the years and I should simplify it at some point. At the moment, if it looks fine on my Lenovo P52, I'm happy.

Other ideas and next steps

Theoretically, the right margin is now available for sidenotes, so I might be able to look at ox-tufte and Eleventufte and get something going. Then I'll have a way to add small notes that are shorter than a paragraph. Longer tangents can go in a details/summary element instead, although I have it on good authority that one can write at length in footnotes. I love the footnotes in the Bartimaeus series, and apparently there are quite a few books where the footnotes are part of the storytelling.)

It might be nice to let tables extend into the right sidebar when I know I won't have a doodle nearby. Incidentally, Sidenotes In Web Design · Gwern.net uses breadcrumbs in the left sidebar instead of a table of contents, so there's more space for tables and sidenotes.

I thought about using CSS breakpoints so that on a medium-sized screen, we can have the left sidebar even if there's no space for something on the right. I haven't gotten around to experimenting with it yet, though. Besides, I don't know yet if I want to prioritize the stuff I want in the right sidebar (side notes, doodles) over fairly-static navigation.

As I mentioned, it might be handy to tweak my SVG linking workflow to use absolute URLs.

Sometimes I look up my notes within Emacs, but surprisingly often, I look them up on the Web. Navigation isn't just cosmetic. I want to get better at using my blog as a tool for thought, so tinkering with layout isn't just window dressing. It's (very slowly) experimenting with scaffolding for my brain. Little things can help!

Using Emacs Lisp to batch-demote HTML headings for my static site

| blogging, 11ty, emacs

Assumed audience: People who have lots of HTML files used as input for a static site generator, might need to do a batch operation on them, and are open to doing that with Emacs Lisp. Which might just be me, but who knows? =)

HTML defines a hierarchy of headings going from <h1> to <h6>, which comes in especially handy when people are navigating with a screenreader or converting web pages to Org Mode. I think search engines might use them to get a sense of the page's structure, too. On my blog, the hierarchy usually goes like this:

  • <h1>: site title,
  • <h2>: blog post titles, since I put multiple blog posts on the main page and category pages (ex: blogging)
  • <h3>: blog post's subheadings, if any
  • <h4>: I rarely need subsubheadings in my main blog posts, but they're there just in case

While fiddling with my blog's CSS so that I could try this fluid type scale, I realized that the subheadings in my exported blog entries started at <h2> instead of <h3>. This meant that the outline was this:

  • Site title
    • Blog post 1
    • Subheading 1
    • Subheading 2
    • Blog post 2
    • Subheading 1
    • Subheading 2
    • Blog post 3

I wanted the outline to be this:

  • Site title
    • Blog post 1
      • Subheading 1
      • Subheading 2
    • Blog post 2
      • Subheading 1
      • Subheading 2
    • Blog post 3

This was because I hadn't changed org-html-toplevel-hlevel during my 11ty export process. To solve this for new posts, I added a new option org-11ty-toplevel-hlevel that defaults to 3 in ox-11ty.el, re-exported one of my long blog posts to test it, and confirmed that my headings now started at <h3>.

I still had all my old HTML files with the wrong levels of headings. I wrote some Emacs Lisp to shift the headings downwards (h5 to h6, h4 to h5, h3 to h4, h2 to h3) in a file if it had an <h2> in it. Regular expressions are usually not a good idea when it comes to HTML because there might be exceptions, but I figured it was a pretty small and low-risk change, so I decided not to use the full XML/DOM parsing functions. I saved all the blog posts under version control just in case I messed things up. Here's my function:

(defun my-html-shift-headings (filename)
  "Shift heading tags in FILENAME."
  (interactive "FFile: ")
  (let ((case-fold-search t)) ; make the search case-insensitive
    (with-temp-buffer
      (insert-file-contents filename)
      (goto-char (point-min))
      ;; Only modify the files where we have an h2
      (when (or (search-forward "<h2" nil t)
                (search-forward "</h2>" nil t))
        (goto-char (point-min))
        ;; Handle both opening and closing tags
        (while (re-search-forward "<\\(/\\)?h\\([2-5]\\)\\>" nil t)
          (let* ((closing-tag (match-string 1))
                 (heading-level (string-to-number (match-string 2)))
                 (new-level (1+ heading-level)))
            (replace-match (concat "<" closing-tag "h" (number-to-string new-level)))))
        (write-file filename)
        filename))))

Running it on all the source HTML files in specific subdirectories was easy with directory-files-recursively.

(dolist (dir '("~/proj/static-blog/blog"
               "~/proj/static-blog/content"))
  (mapc 'my-html-shift-headings
        (directory-files-recursively
         dir
         "\\.html\\'")))

Then I could just rebuild my blog and get all the right heading levels. Spot-checks with Inspect Element show that the headings now have the right tags, and org-web-tools-read-url-as-org now picks up the right hierarchy for the page.

Correcting the input files was easier and more efficient than modifying my 11ty template engine to shift the heading levels whenever I build my site (probably by defining a preprocessor). I could've written a NodeJS script to do that kind of file manipulation, but writing it in Emacs Lisp matched how I might think of doing it interactively. Using Emacs Lisp was also easy to test on one or two files, check the list of files matched by directory-files-recursively, and then run it on everything.

Going forward, the new org-11ty-toplevel-hlevel variable should properly modify the behaviour of Org's HTML export to get the headings at the right level. We'll see!

Moving 18 years of comments out of Disqus and into my 11ty static site

| 11ty, blogging

Assumed audience: Technical bloggers who like:

  • static site generators: this post is about moving more things into my SSG
  • XML: check out the mention of xq, which offers a jq-like interface
  • or Org Mode: some notes here about Org Babel source blocks and graphing

I've been thinking of getting rid of the Disqus blog commenting system for a while. I used to use it in the hopes that it would handle spam filtering and the "someone has replied to your comment" notification for me. Getting rid of Disqus means one less thing that needs Javascript, one less thing that tracks people in ways we don't want, one less thing that shows ads and wants to sell our attention. Comments are rare enough these days, so I think I can handle e-mailing people when there are replies.

There are plenty of alternative commenting systems to choose from. Comentario and Isso are self-hosted, while Commento (USD 10/month) and Hyvor Talk (12 euro/month) are services. Utterances uses Github issues, which is probably not something I'll try as quite a few people in the Emacs community are philosophically opposed to Github. Along those lines, if I can find something that works without Javascript, that would be even better.

I could spend a few years trying to figure out which system I might like in terms of user interface, integration, and spam-filtering, but for now, I want to:

Fortunately, there's 11ty/eleventy-import-disqus (see zachleat's blog post: Import your Disqus Comments to Eleventy)

Exploring my disqus.xml with xq, Org Babel, and seaborn

One challenge: there are a lot of comments. How many? I got curious about analyzing the XML, and then of course I wanted to do that from Emacs. I used pipx install yq to install yq so that I could use the xq tool to query the XML, much like jq works.

My uncompressed Disqus XML export was 28MB. I spent some time deleting spam comments through the web interface, which helped with the filtering. I also deleted some more comments from the XML file as I noticed them. I needed to change /wp/ to /blog/, too.

This is how I analyzed the archive for non-deleted posts, uniquified based on message. I'll include the full Org source of that block (including the header lines) in my blog post so that you can see how I call it later.

#+NAME: analyze-disqus
#+begin_src shell :var rest="| length | \"\\(.) unique comments\"" :exports results
~/.local/bin/xq -r "[.disqus.post[] |
   select(.isDeleted != \"true\" and .message) |
   {key: .message, value: .}] |
  map(.value) |
  unique_by(.message) ${rest}" < disqus.xml
#+end_src

When I evaluate that with C-c C-c, I get:

8265 unique comments

I was curious about how it broke down by year. Because I named the source code block and used a variable to specify how to process the filtered results earlier, I can call that with a different value.

Here's the call in my Org Mode source:

#+CALL: analyze-disqus(rest="| map(.createdAt[0:4]) | group_by(.) | map([(.[0]), length]) | reverse | [\"Year\", \"Count\"], .[] | @csv") :results table output :wrap my_details Table of comment count by year
Table of comment count by year
Year Count
2025 26
2024 43
2023 34
2022 40
2021 55
2020 131
2019 107
2018 139
2017 186
2016 196
2015 593
2014 740
2013 960
2012 784
2011 924
2010 966
2009 1173
2008 1070
2007 98

I tried fiddling around with Org's #+PLOT keyword, but I couldn't figure out how to get the bar graph the way I wanted it to be. Someday, if I ever figure that out, I'll definitely save the Gnuplot setup as a snippet. For now, I visualized it using seaborn instead.

Code for graphing comments by year
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np

df = pd.DataFrame(data[1:], columns=data[0])
df['Count'] = df['Count'].astype(int)
df['Year'] = df['Year'].astype(int)
df = df.sort_values('Year')
plt.figure(figsize=(12, 6))
ax = sns.barplot(x='Year', y='Count', data=df)
plt.title('Comments by Year (2007-2025)', fontsize=16, fontweight='bold')
plt.xlabel('Year')
plt.ylabel('Comments')
plt.xticks(rotation=45)
plt.grid(axis='y')
for i, v in enumerate(df['Count']):
    ax.text(i, v + 20, str(v), ha='center', fontsize=9)
plt.tight_layout()
plt.savefig('year_count_plot.svg')
return 'year_count_plot.svg'
year_count_plot.svg

Ooooooh, I can probably cross-reference this with the number of posts from my /blog/all/index.json file. I used Claude AI's help to come up with the code below, since merging data and plotting them nicely is still challenging for me. Now that I have the example, though, maybe I can do other graphs more easily. (This looks like a related tutorial on combining barplots and lineplots.)

Code for graphing
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import json
from matplotlib.ticker import FuncFormatter
from datetime import datetime

with open('/home/sacha/proj/static-blog/_site/blog/all/index.json', 'r') as f:
    posts_data = json.load(f)

# Process post data
posts_df = pd.DataFrame(posts_data)
posts_df['Year'] = pd.to_datetime(posts_df['date']).dt.year
post_counts = posts_df.groupby('Year').size().reset_index(name='post_count')

# Convert to DataFrame
comments_df = pd.DataFrame(comment_data[1:], columns=comment_data[0])
comments_df['Count'] = comments_df['Count'].astype(int)
comments_df['Year'] = comments_df['Year'].astype(int)

# Merge the two dataframes
merged_df = pd.merge(post_counts, comments_df, on='Year', how='outer').fillna(0)
merged_df = merged_df.sort_values('Year')

# Calculate comments per post ratio
merged_df['comments_per_post'] = merged_df['Count'] / merged_df['post_count']
merged_df['comments_per_post'] = merged_df['comments_per_post'].replace([np.inf, -np.inf], np.nan).fillna(0)

# Create a single figure instead of two subplots
fig, ax1 = plt.subplots(figsize=(15, 8))

# Custom colors
post_color = "#1f77b4"    # blue
comment_color = "#ff7f0e" # orange
ratio_color = "#2ca02c"   # green

# Setting up x-axis positions
x = np.arange(len(merged_df))
width = 0.35

# Bar charts on first y-axis
bars1 = ax1.bar(x - width/2, merged_df['post_count'], width, color=post_color, label='Posts')
bars2 = ax1.bar(x + width/2, merged_df['Count'], width, color=comment_color, label='Comments')
ax1.set_ylabel('Count (Posts & Comments)', fontsize=12)

# Add post count values above bars
for i, bar in enumerate(bars1):
    height = bar.get_height()
    if height > 0:
        ax1.text(bar.get_x() + bar.get_width()/2., height + 5,
                f'{int(height)}', ha='center', va='bottom', color=post_color, fontsize=9)

# Add comment count values above bars
for i, bar in enumerate(bars2):
    height = bar.get_height()
    if height > 20:  # Only show if there's enough space
        ax1.text(bar.get_x() + bar.get_width()/2., height + 5,
                f'{int(height)}', ha='center', va='bottom', color=comment_color, fontsize=9)

# Line graph on second y-axis
ax2 = ax1.twinx()
line = ax2.plot(x, merged_df['comments_per_post'], marker='o', color=ratio_color,
              linewidth=2, label='Comments per Post')
ax2.set_ylabel('Comments per Post', color=ratio_color, fontsize=12)
ax2.tick_params(axis='y', labelcolor=ratio_color)
ax2.set_ylim(bottom=0)

# Add ratio values near line points
for i, ratio in enumerate(merged_df['comments_per_post']):
    if ratio > 0:
        ax2.text(i, ratio + 0.2, f'{ratio:.1f}', ha='center', color=ratio_color, fontsize=9)

# Set x-axis labels
ax1.set_xticks(x)
ax1.set_xticklabels(merged_df['Year'], rotation=45)
ax1.set_title('Blog Posts, Comments, and Comments per Post by Year', fontsize=16, fontweight='bold')
ax1.grid(axis='y')

# Add combined legend
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')

# Layout and save
plt.tight_layout()
plt.savefig('posts_comments_analysis.svg')
return 'posts_comments_analysis.svg'
posts_comments_analysis.svg

Timeline notes:

  • In this graph, comments are reported by the timestamp of the comment, not the date of the post.
  • In 2007 or so, I moved to Wordpress from planner-rss.el. I think I eventually imported those Wordpress comments into Disqus when I got annoyed with Wordpress comments (Akismet? notifications?).
  • In 2008 and 2009, I was working on enterprise social computing at IBM. I made a few presentations that were popular. Also, mentors and colleagues posted lots of comments.
  • In 2012, I started my 5-year experiment with semi-retirement.
  • In 2016, A+ was born, so I wrote much fewer posts.
  • In 2019/2020, I wrote a lot of blog posts documenting how I was running EmacsConf with Emacs, and other Emacs tweaks along the way. The code is probably very idiosyncratic (… unless you happen to know other conference organizers who like to do as much as possible within Emacs? Even then, there are lots of assumptions in the code), but maybe people picked up useful ideas anyway. =)

What were my top 20 most-commented posts?

Emacs Lisp code for most-commented posts
(let* ((json-object-type 'alist)
       (json-array-type 'list)
       (comments-json (json-read-file "~/proj/static-blog/_data/commentsCounts.json"))
       (posts-json (json-read-file "~/proj/static-blog/_site/blog/all/index.json"))
       (post-map (make-hash-table :test 'equal)))
  ;; map permalink to title
  (dolist (post posts-json)
    (let ((permalink (cdr (assoc 'permalink post)))
          (title (cdr (assoc 'title post))))
      (puthash permalink title post-map)))
  ;; Sort comments by count (descending)
  (mapcar
   (lambda (row)
     (list
      (cdr row)
            (org-link-make-string
       (concat "https://sachachua.com" (symbol-name (car row)))
       (with-temp-buffer
         (insert (or (gethash (symbol-name (car row)) post-map) (symbol-name (car row))))
         (mm-url-decode-entities)
         (buffer-string)))))
   (seq-take
    (sort comments-json
          (lambda (a b) (> (cdr a) (cdr b))))
    n)))
97 blog/contact
88 Even more awesome LotusScript mail merge for Lotus Notes + Microsoft Excel
75 blog/about
45 How to Learn Emacs: A Hand-drawn One-pager for Beginners / A visual tutorial
42 Planning an Emacs-based personal wiki – Org? Muse? Hmm…
38 Married!
37 Moving from testing to development
36 What can I help you learn? Looking for mentees
33 Lotus Notes mail merge from a Microsoft Excel spreadsheet
30 Nothing quite like Org for Emacs
30 Org-mode and habits
29 zomg, Evernote and Emacs
25 Literate programming and my Emacs configuration file
25 Reinvesting time and money into Emacs
23 The Gen Y Guide to Web 2.0 at Work
22 Drupal: Overriding Drupal autocompletion to pass more parameters
21 Rhetoric and the Manila Zoo; reflections on conversations and a request for insight
20 This is a test post from org2blog
19 Agendas
19 Paper, Tablet, and Tablet PC: Comparing tools for sketchnoting

Top 3 by year. Note that this goes by the timestamp of the post, not the comment, so even old posts are in here.

Emacs Lisp code for most-commented posts by year
(let* ((json-object-type 'alist)
       (json-array-type 'list)
       (comments-json (json-read-file "~/proj/static-blog/_data/commentsCounts.json"))
       (posts-json (json-read-file "~/proj/static-blog/_site/blog/all/index.json"))
       by-year)
  (setq posts-json
        (mapcar
         (lambda (post)
           (let ((comments (alist-get (intern (alist-get 'permalink post)) comments-json)))
             (if comments
                 (cons (cons 'comments (alist-get (intern (alist-get 'permalink post)) comments-json 0))
                       post)
               post)))
         posts-json))
  (setq by-year
        (seq-group-by
         (lambda (o)
           (format-time-string "%Y"
                               (date-to-time
                                (alist-get 'date o))
                               "America/Toronto"))
         (seq-filter (lambda (o) (alist-get 'comments o)) posts-json)))
  (org-list-to-org
   (cons 'unordered
         (seq-keep
          (lambda (year)
            (list
             (org-link-make-string (concat "https://sachachua.com/blog/" (car year))
                                   (car year))
             (cons 'unordered
                   (mapcar
                    (lambda (entry)
                      (list (format "%s (%d)"
                                    (org-link-make-string
                                     (concat "https://sachachua.com" (alist-get 'permalink entry))
                                     (with-temp-buffer
                                       (insert (alist-get 'title entry))
                                       (mm-url-decode-entities)
                                       (buffer-string)))
                                    (alist-get 'comments entry))))
                    (seq-take
                     (sort
                      (cdr year)
                      (lambda (a b) (> (alist-get 'comments a)
                                       (alist-get 'comments b))))
                     n)))))
          (nreverse by-year)))))

As you can probably tell, I love writing about Emacs, especially when people drop by in the comments to:

  • share that they'd just learned about some small thing I mentioned in passing and that it was really useful for this other part of their workflow that I totally wouldn't have guessed
  • point out a simpler package or built-in Emacs function that also does whatever clever hack I wrote about, just in a more polished way
  • link to a blog post or code snippet where they've borrowed the idea and added their own spin

I want to keep having those sorts of conversations.

Deleting spam comments via the Disqus web interface and Spookfox

8000+ comments are a lot to read, but it should be pretty straightforward to review the comments at least until 2016 or so, and then just clean out spam as I come across it after that. I used the Disqus web interface to delete spam comments since the isSpam attribute didn't seem to be reliable. The web interface pages through comments 25 items at a time and doesn't seem to let you select all of them, so I started tinkering around with using Spookfox to automate this. Spookfox lets me control Mozilla Firefox from Emacs Lisp.

(progn
  ;; select all
  (spookfox-eval-js-in-active-tab "document.querySelector('.mod-bar__check input').click()")
  (wait-for 1)
  ;; delete
  (spookfox-eval-js-in-active-tab "document.querySelectorAll('.mod-bar__button')[2].click()")
  (wait-for 2)
  ;; click OK, which should make the list refresh
  (spookfox-eval-js-in-active-tab "btn = document.querySelectorAll('.mod-bar__button')[1]; if (btn.textContent.match('OK')) btn.click();")
  (wait-for 4)
  ;; backup: (spookfox-eval-js-in-active-tab "window.location.href = 'https://sachac.disqus.com/admin/moderate/spam'")
  )

I got to the end of the spam comments after maybe 10 or 20 pages, though, so maybe Disqus had auto-deleted most of the spam comments.

It's almost amusing, paging through all these spammy attempts at link-building and product promotion. I didn't want to click on any of the links since there might be malware, so sometimes I used curl to check the site. Most of the old spam links I checked don't even have working domains any more. Anything that needed spam didn't really have lasting power. It was all very "My name is Ozymandias, king of kings: / Look on my works, ye Mighty, and despair!"… and then gone.

Modifying eleventy-import-disqus for my site

Back to eleventy-import-disqus. I followed the directions to make a contentMap.json and removed the trailing , from the last entry so that the JSON could be parsed.

Modifications to eleventy-import-disqus:

  • The original code created all the files in the same directory, so I changed it to create the same kind of nested structure I use (generally ./blog/yyyy/mm/post-slug/index.html and ./blog/yyyy/mm/post-slug/index.11tydata.json). I decided to store the Disqus comments in index.json, which is lower-priority than .11tydata.json. fs-extra made this easier by creating all the parent directories.
  • Ignored deleted messages
  • Discarded avatars
  • Did some reporting to help me review potential spam
  • Reparented messages if I deleted their parent posts
  • Indent the thread JSON nicely in case I want to add or remove comments by hand

With the thread JSON files, my blog takes 143 seconds to generate, versus 133 seconds without the comments. +10 seconds isn't too bad. I was worried that it would be longer, since I added 2,088 data JSON files to the build process, but I guess 11ty is pretty efficient.

Next steps

It had been nice to have a comment form that people could fill in from anywhere and which shared their comments without needing my (often delayed) intervention. I learned lots of things from what people shared. Sometimes people even had discussions with each other, which was extra cool. Still, I think it might be a good time to experiment with alternatives. Plain e-mail for now, I guess, maybe with a nudge asking people if I could share their comments. Mastodon, too - could be fun to make it easy to add a toot to the static comments from mastodon.el or from my Org Mode inbox. (Update 2025-03-30: Adding Mastodon toots as comments in my 11ty static blog) Might be good to figure out Webmentions, too. (But then other people have been dealing with spam Webmentions, of course.)

Comment counts can be useful social signals for interesting posts. I haven't added comment counts to the lists of blog posts yet. eleventy-import-disqus created a commentsCounts.json, which I could use in my templates. However, I might change the comments in the per-post .json file if I figure out how to include Mastodon comments, so I may need to update that file or recalculate it from the posts.

Many of the blogs I read have shifted away from commenting systems, and the ones who still have comments on seem to be bracing for AI-generated comment spam. I'm not sure I like the way the Internet is moving, but maybe in this little corner, we can still have conversations across time. Comments are such a wonderful part of learning out loud. I wonder how we can keep learning together.

View org source for this post

Tweaking my 11ty blog to link to the Mastodon post defined in an Org Mode property

| 11ty, mastodon, org

One of the things I like about blogging from Org Mode in Emacs is that it's easy to add properties to the section that I'm working on and then use those property values elsewhere. For example, I've modified Emacs to simplify tooting a link to my blog post and saving the Mastodon status URL in the EXPORT_MASTODON property. Then I can use that in my 11ty static site generation process to include a link to the Mastodon thread as a comment option.

First, I need to export the property and include it in the front matter. I use .11tydata.json files to store the details for each blog post. I modified ox-11ty.el so that I could specify functions to change the front matter (org-11ty-front-matter-functions, org-11ty--front-matter):

(defvar org-11ty-front-matter-functions nil
  "Functions to call with the current front matter plist and info.")
(defun org-11ty--front-matter (info)
  "Return front matter for INFO."
  (let* ((date (plist-get info :date))
         (title (plist-get info :title))
         (modified (plist-get info :modified))
         (permalink (plist-get info :permalink))
         (categories (plist-get info :categories))
         (collections (plist-get info :collections))
         (extra (if (plist-get info :extra) (json-parse-string
                                             (plist-get info :extra)
                                             :object-type 'plist))))
    (seq-reduce
     (lambda (prev val)
       (funcall val prev info))
     org-11ty-front-matter-functions
     (append
      extra
      (list :permalink permalink
            :date (if (listp date) (car date) date)
            :modified (if (listp modified) (car modified) modified)
            :title (if (listp title) (car title) title)
            :categories (if (stringp categories) (split-string categories) categories)
            :tags (if (stringp collections) (split-string collections) collections))))))

Then I added the EXPORT_MASTODON Org property as part of the front matter. This took a little figuring out because I needed to pass it as one of org-export-backend-options, where the parameter is defined as MASTODON but the actual property needs to be called EXPORT_MASTODON.

(defun my-org-11ty-add-mastodon-to-front-matter (front-matter info)
  (plist-put front-matter :mastodon (plist-get info :mastodon)))
(with-eval-after-load 'ox-11ty
  (cl-pushnew
   '(:mastodon "MASTODON" nil nil)
   (org-export-backend-options (org-export-get-backend '11ty)))
  (add-hook 'org-11ty-front-matter-functions #'my-org-11ty-add-mastodon-to-front-matter))

Then I added the Mastodon field as an option to my comments.cjs shortcode. This was a little tricky because I'm not sure I'm passing the data correctly to the shortcode (sometimes it ends up as item.data, sometimes it's item.data.data, …?), but with ?., I can just throw all the possibilities in there and it'll eventually find the right one.

const pluginRss = require('@11ty/eleventy-plugin-rss');
module.exports = function(eleventyConfig) {
  function getCommentChoices(data, ref) {
    const mastodonUrl = data.mastodon || data.page?.mastodon || data.data?.mastodon;
    const mastodon = mastodonUrl && `<a href="${mastodonUrl}" target="_blank" rel="noopener noreferrer">comment on Mastodon</a>`;
    const url = ref.absoluteUrl(data.url || data.permalink || data.data?.url || data.data?.permalink, data.metadata?.url || data.data?.metadata?.url);
    const subject = encodeURIComponent('Comment on ' + url);
    const body = encodeURIComponent("Name you want to be credited by (if any): \nMessage: \nCan I share your comment so other people can learn from it? Yes/No\n");
    const email = `<a href="mailto:sacha@sachachua.com?subject=${subject}&body=${body}">e-mail me at sacha@sachachua.com</a>`;
    const disqusLink = url + '#comment';
    const disqusForm = data.metadata?.disqusShortname && `<div id="disqus_thread"></div>
<script>
 var disqus_config = function () {
   this.page.url = "${url}";
   this.page.identifier = "${data.id || ''} ${data.metadata?.url || ''}?p=${ data.id || data.permalink || this.page?.url}";
   this.page.disqusTitle = "${ data.title }"
   this.page.postId = "${ data.id || data.permalink || this.page?.url }"
 };
 (function() { // DON'T EDIT BELOW THIS LINE
   var d = document, s = d.createElement('script');
   s.src = 'https://${ data.metadata?.disqusShortname }.disqus.com/embed.js';
   s.setAttribute('data-timestamp', +new Date());
   (d.head || d.body).appendChild(s);
 })();
</script>
<noscript>Disqus requires Javascript, but you can still e-mail me if you want!</noscript>`;
    return { mastodon, disqusLink, disqusForm, email };
  }
  eleventyConfig.addShortcode('comments', function(data, linksOnly=false) {
    const { mastodon, disqusForm, disqusLink, email } = getCommentChoices(data, this);
    if (linksOnly) {
      return `You can ${mastodon ? mastodon + ', ' : ''}<a href="${disqusLink}">comment with Disqus (JS required)</a>${mastodon ? ',' : ''} or ${email}.`;
    } else {
      return `<div id="comment"></div>
You can ${mastodon ? mastodon + ', ' : ''}comment with Disqus (JS required)${mastodon ? ', ' : ''} or you can ${email}.
${disqusForm || ''}`;}
  });
}

I included it in my post.cjs shortcode:

module.exports = eleventyConfig =>
eleventyConfig.addShortcode('post', async function(item, index, includeComments) {
  let comments = '<div class="comments">' + (includeComments ? this.comments(item) : this.comments(item, true)) + '</div>';
  let categoryList = item.categories || item.data && item.data.categories;
  let categoriesFooter = '', categories = '';
  if (categoryList && categoryList.length > 0) {
    categoriesFooter = `<div class="footer-categories">More posts about ${this.categoryList(categoryList)}</div>`;
    categories = `| <span class="categories">${this.categoryList(categoryList)}</span>`;
  }

  return  `<article class="post" id="index${index}" data-url="${item.url || item.permalink || ''}">
<header><h2 data-pagefind-meta="title"><a href="${item.url || item.permalink || ''}">${item.title || item.data && item.data.title}</a></h2>
${this.timeInfo(item)}${categories}
</header>
<div class="entry">
${await (item.templateContent || item.layoutContent || item.data?.content || item.content || item.inputContent)}
</div>
${comments}
${categoriesFooter}
</article>`;
});

I also included it in my RSS item template to make it easier for people to send me comments without having to dig through my website for contact info.

const posthtml = require("posthtml");
const urls = require("posthtml-urls");

module.exports = (eleventyConfig) => {
  eleventyConfig.addAsyncShortcode('rssItem', async function(item) {
    let content = item.templateContent.replace(/--/g, '&#45;&#45;');
    if (this.transformWithHtmlBase) {
      content = await this.transformWithHtmlBase(content);
    }
    return `<item>
    <title>${item.data.title}</title>
    <link>${this.absoluteUrl(item.url, item.data.metadata.url)}</link>
    <dc:creator><![CDATA[${item.data.metadata.author.name}]]></dc:creator>
    <pubDate>${item.date.toUTCString()}</pubDate>
    ${item.data.categories?.map((cat) => `<category>${cat}</category>`).join("\n") || ''}
    <guid isPermaLink="false">${this.guid(item)}</guid>
    <description><![CDATA[${content}
<p>${this.comments(item, true)}</p>]]></description>
    </item>`;
  });
};

The new workflow I'm trying out seems to be working:

  1. Keep npx eleventy --serve running in the background, using .eleventyignore to make rebuilds reasonably fast.
  2. Export the subtree with C-c e s 1 1, which uses org-export-dispatch to call my-org-11ty-export with the subtree.
  3. After about 10 seconds, use my-org-11ty-copy-just-this-post and verify.
  4. Use my-mastodon-11ty-toot-post to compose a toot. Edit the toot and post it.
  5. Check that the EXPORT_MASTODON property has been set.
  6. Export the subtree again, this time with the front matter.
  7. Publish my whole blog.

Next, I'm thinking of modifying my-mastodon-11ty-toot-post so that it includes a list of links to blog posts I might be building on or responding to, and possibly the handles of people related to those blog posts or topics. Hmm…

View org source for this post

mastodon.el: Copy toot URL after posting; also, copying just this post with 11ty

| mastodon, emacs, 11ty

I often want to copy the toot URL after posting a new toot about a blog post so that I can update the blog post with it. Since I post from Emacs using mastodon.el, I can probably figure out how to get the URL after tooting. A quick-and-dirty way is to retrieve the latest status.

(defvar my-mastodon-toot-posted-hook nil "Called with the item.")

(defun my-mastodon-copy-toot-url (toot)
  (interactive (list (my-mastodon-latest-toot)))
  (kill-new (alist-get 'url toot)))
(add-hook 'my-mastodon-toot-posted-hook #'my-mastodon-copy-toot-url)

(defun my-mastodon-latest-toot ()
  (interactive)
  (require 'mastodon-http)
  (let* ((json-array-type 'list)
         (json-object-type 'alist))
    (car
     (mastodon-http--get-json
      (mastodon-http--api
       (format "accounts/%s/statuses?count=1&limit=1&exclude_reblogs=t"
               (mastodon-auth--get-account-id)))
      nil :silent))))

(with-eval-after-load 'mastodon-toot
  (when (functionp 'mastodon-toot-send)
    (advice-add
     #'mastodon-toot-send
     :after
     (lambda (&rest _)
       (run-hook-with-args 'my-mastodon-toot-posted-hook (my-mastodon-latest-toot)))))
  (when (functionp 'mastodon-toot--send)
    (advice-add
     #'mastodon-toot--send
     :after
     (lambda (&rest _)
       (run-hook-with-args 'my-mastodon-toot-posted-hook (my-mastodon-latest-toot))))))

I considered overriding the keybinding in mastodon-toot-mode-map, but I figured using advice would mean I can copy things even after automated toots.

A more elegant way to do this might be to modify mastodon-toot-send to run-hook-with-args a variable with the response as an argument, but this will do for now.

I used a hook in my advice so that I can change the behaviour from other functions. For example, I have some code to compose a toot with a link to the current post. After I send a toot, I want to check if the toot contains the current entry's permalink. If it has and I don't have a Mastodon toot field yet, maybe I can automatically set that property, assuming I end up back in the Org Mode file I started it from.

(defun my-mastodon-org-maybe-set-toot-url (toot)
  (when (derived-mode-p 'org-mode)
    (let ((permalink (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK")))
      (when (and permalink
                 (string-match (regexp-quote permalink) (alist-get 'content toot))
                 (not (org-entry-get-with-inheritance "MASTODON")))
        (save-excursion
          (goto-char (org-find-property "EXPORT_ELEVENTY_PERMALINK"
                                        permalink))
          (org-entry-put
           (point)
           "EXPORT_MASTODON"
           (alist-get 'url toot))
          (message "Toot URL set: %s, republish if needed" toot))))))
(add-hook 'my-mastodon-toot-posted-hook #'my-mastodon-org-maybe-set-toot-url)

If I combine that with a development copy of my blog that ignores most of my posts so it compiles faster and a function that copies just the current post's files over, I can quickly make a post available at its permalink (which means the link in the toot will work) before I recompile the rest of the blog, which takes a number of minutes.

(defun my-org-11ty-copy-just-this-post ()
  (interactive)
  (when (derived-mode-p 'org-mode)
    (let ((file (org-entry-get-with-inheritance "EXPORT_ELEVENTY_FILE_NAME"))
          (path my-11ty-base-dir))
      (call-process "chmod" nil nil nil "ugo+rwX" "-R" (expand-file-name file (expand-file-name "_local" path)))
      (call-process "rsync" nil (get-buffer-create "*rsync*") nil "-avze" "ssh"
                    (expand-file-name file (expand-file-name "_local" path))
                    (concat "web:/var/www/static-blog/" file))
      (browse-url (concat (replace-regexp-in-string "/$" "" my-blog-base-url)
                          (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK"))))))

The proper blog updates (index page, RSS/ATOM feeds, category pages, prev/next links, etc.) can happen when the publishing is finished.

So my draft workflow is:

  1. Write the post.
  2. Export it to the local NODE_ENV=dev npx eleventy --serve --quiet with ox-11ty.
  3. Check that it looks okay locally.
  4. Use my-org-11ty-copy-just-this-post and confirm that it looks fine.
  5. Compose a toot with my-mastodon-11ty-toot-post and check if sending it updates the Mastodon toot.
  6. Re-export the post.
  7. Run my blog publishing process. NODE_ENV=production npx eleventy --quiet and then rsync.

Let's see if this works…

This is part of my Emacs configuration.
View org source for this post

On this day

| 11ty, js

Nudged by org-daily-reflection (@emacsomancer's toot) and Jeremy Keith's post where he mentions his on this day page, I finally got around to making my own on this day page again. I use the 11ty static site generator, so it's static unless you have Javascript enabled. It might be good for bumping into things. I used to have an "On this day" widget back when I used Wordpress, which was fun to look at occasionally.

The code might be a little adamant about converting all the dates to America/Toronto:

11ty code for posts on this day
export default class OnThisDay {
  data() {
    return {
      layout: 'layouts/base',
      permalink: '/blog/on-this-day/',
      title: 'On this day'
    };
  }

  async render(data) {
    const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/Toronto' }));
    const options = { month: 'long', day: 'numeric' };
    const date = today.toLocaleDateString('en-US', options);
    const currentMonthDay = today.toISOString().substring(5, 10);
    let list = data.collections._posts
        .filter(post => {
          const postDateTime = new Date(post.date).toLocaleString('en-US', { timeZone: 'America/Toronto' });
          const postMonthDay = (new Date(postDateTime)).toISOString().substring(5, 10);
          return postMonthDay === currentMonthDay;
        })
        .sort((a, b) => {
          if (a.date < b.date) return 1;
          if (a.date > b.date) return -1;
          return 0;
        })
        .map(post => {
          const postDateTime = new Date(post.date).toLocaleString('en-US', { timeZone: 'America/Toronto' });
          const postDate = new Date(postDateTime);
          const postYear = postDate.getFullYear();
          return `<li>${postYear}: <a href="${post.url}">${post.data.title}</a></li>`;
        })
        .join('\n');
    list = list.length > 0
      ? `<ul>${list}</ul>`
      : `<p>No posts were written on ${date} in previous years.</p>`;

    return `<section><h2>On this day</h2>
<p>This page lists posts written on this day throughout the years. If you've enabled Javascript, it will show the current day. If you don't, it'll show the posts from the day I last updated this blog. You might also like to explore <a href="/blog/all">all posts</a>, <a href="/topic">a topic-based outline</a> or <a href="/blog/category">categories</a>.</p>
<h3 class="date">${date}</h3>
<div id="posts-container">${list}</div>

<script>
  $(document).ready(function() { onThisDay(); });
</script>
</section>`;
  }
};
Client-side Javascript for the dynamic list
function onThisDay() {
  const tz = 'America/Toronto';
  function getEffectiveDate() {
    const urlParams = new URLSearchParams(window.location.search);
    const dateParam = urlParams.get('date');
    if (dateParam && /^\d{2}-\d{2}$/.test(dateParam)) {
      const currentYear = new Date().getFullYear();
      const dateObj = new Date(`${currentYear}-${dateParam}T12:00:00Z`);
      if (dateObj.getTime()) {
        return {
          monthDay: dateParam,
          formatted: dateObj.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
        };
      }
    }
    const today = new Date(new Date().toLocaleString('en-US', { timeZone: tz }));
    return {
      monthDay: today.toISOString().substring(5, 10), // MM-DD
      formatted: today.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
    };
  }
  // Fetch and process the posts
  fetch('/blog/all/index.json')
    .then(response => response.json())
    .then(posts => {
      const dateInfo = getEffectiveDate();
      const dateElement = document.querySelector('h3.date');
      if (dateElement) {
        dateElement.textContent = dateInfo.formatted;
      }
      const matchingPosts = posts.filter(post => {
        const postDate = new Date(post.date).toLocaleString('en-US', { timeZone: tz });
        const postMonthDay = (new Date(postDate)).toISOString().substring(5, 10);
        return postMonthDay === dateInfo.monthDay;
      });

      matchingPosts.sort((a, b) => {
        const dateA = new Date(a.date);
        const dateB = new Date(b.date);
        return dateB - dateA;
      });

      const elem = document.getElementById('posts-container');
      if (matchingPosts.length > 0) {
        const postsHTML = matchingPosts.map(post => {
          const postDate = new Date(post.date).toLocaleString('en-US', { timeZone: tz });
          const postYear = new Date(postDate).getFullYear();
          return `<li>${postYear}: <a href="${post.permalink}">${post.title}</a></li>`;
        }).join('\n');
        elem.innerHTML = `<ul>${postsHTML}</ul>`;
      } else {
        elem.innerHTML = `<p>No posts were written on ${dateInfo.formatted}.</p>`;
      }
    })
    .catch(error => {
      console.error('Error fetching posts:', error);
    });
}

I used to include the day's posts as a footer on the individual blog post page. That might be something to consider again.

View org source for this post

Organizing my visual book notes by topic

| blogging, 11ty

I want to start building up more thoughts as chunks and relating them more logically instead of just chronologically. I've been using categories to organize my posts into buckets, but within a category, it's still chronological. I also have a large outline that includes posts from 2017 to 2024. I'd like to break it up into smaller topic pages so that they're easier to link to, although it's a little more challenging to search.

Now that I have a nice gallery view for my visual book notes, I wanted to organize the book notes by topic. I made an async Eleventy paired shortcode called gallerylist that lets me turn a list of links into into thumbnails and links.

I also modified org-html-toc to not include the Table of Contents header and to tweak the HTML attributes assigned to it.

New table of contents code
(defun my-org-html-toc (depth info &optional scope)
  "Build a table of contents.
DEPTH is an integer specifying the depth of the table.  INFO is
a plist used as a communication channel.  Optional argument SCOPE
is an element defining the scope of the table.  Return the table
of contents as a string, or nil if it is empty."
  (let ((toc-entries
   (mapcar (lambda (headline)
       (cons (org-html--format-toc-headline headline info)
       (org-export-get-relative-level headline info)))
     (org-export-collect-headlines info depth scope))))
    (when toc-entries
      (let* ((toc-id-counter (plist-get info :org-html--toc-counter))
             (toc (concat (format "<div class=\"text-table-of-contents toc-id%s\" role=\"doc-toc\">"
                                  (if toc-id-counter (format "-%d" toc-id-counter) ""))
        (org-html--toc-text toc-entries)
        "</div>\n")))
        (plist-put info :org-html--toc-counter (1+ (or toc-id-counter 0)))
  (if scope toc
    (let ((outer-tag (if (org-html--html5-fancy-p info)
             "nav"
           "div")))
      (concat (format "<%s class=\"table-of-contents toc-id%s\" role=\"doc-toc\">\n"
                            outer-tag
                            (if toc-id-counter (format "-%d" toc-id-counter) ""))
              ;; (let ((top-level (plist-get info :html-toplevel-hlevel)))
              ;; (format "<h%d>%s</h%d>\n"
              ;;   top-level
              ;;   (org-html--translate "Table of Contents" info)
              ;;   top-level))
        toc
        (format "</%s>\n" outer-tag))))))))

(with-eval-after-load 'org
  (defalias 'org-html-toc #'my-org-html-toc))

This is what my visual book notes topic page looks like now:

2024-10-25_09-23-26.png
Figure 1: Screenshot of visual book notes

I can improve on this by using the topic maps to determine next/previous links for the posts. Someday!

View org source for this post