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

Week ending March 28, 2025: mastodon.el tweaks, search, workflows

| review, weekly
  • I've been practising fretting less about homework.
  • I added an On this day page to my blog. (blog post about it)
  • I added Mastodon links to my blog. I think the process will be: post the blog post; toot to Mastodon; edit the blog post and republish. I might be able to save time and just copy over the blog post during the first go-around, from make serve.
  • I added Pagefind search to my blog.
  • I wrote about some of my workflows.
  • I started a /now page.
  • Oops: I forgot to check on Emacs Berlin and it turned out that the NAS timezone was set to GMT-5 instead of America/Toronto, so I scrambled to get it set up. I also got distracted while trying to figure out how to revoke the token the NAS was using so it wouldn't downscale automatically, so that might have wrapped up the meeting early. I set up cronjobs on xu4 for next time.

Next week:

  • Continue to reduce fretting about homework.
  • Work through intermediate piano course in Simply Piano. Practise1 more songs, too.
  • Take a look at that inbox and start dusting things off.

Blog posts

Sketches

Toots

  • eleventy-post-graph (toot) I used eleventy-post-graph to add a quick year visualization to my year pages (2025, 2024, …) and a visualization for the whole blog. Someday it might be nice to make it more accessible and figure out how I can link to the blog post(s) for that day.
  • From @johnrakestraw's On keeping a notebook (toot)

    “One thing that really fascinates me is how I'm reminded of events and readings that I'd completely forgotten – but, once reminded, I find that these things are once again in my mind. Perhaps I can say what I'm thinking more clearly — though I'm more than a little frustrated by having absolutely no memory of experiencing or reading something I describe in an entry written only a few years ago, I'm fascinated by how reading what I wrote has brought that experience back to mind rather vividly. Of course I'm reminded of what I described in the text that I'm now re-reading, but I can also remember other things associated with whatever it is that is described there. It's as though the small bit that I wrote and can now read is the key that unlocks a much larger trove of memory. Funny how the mind works.”

    I am also quite fuzzy about things that happened, and I'm glad I've got notes to help me sort of remember.

  • Added comment links to my RSS feed (toot) Nudged by A Walk Through My Digital Neighborhood // Take on Rules by @takeonrules and also my recent focus on having more conversations around blog post ideas (and sometimes the annoyance of finding someone's contact info), I added comment links to my RSS/Atom items (https://sachachua.com/blog/feed/index.xml and https://sachachua.com/blog/feed/atom/index.xml, and also all the categories have feeds generally at category/…/feed/index.xml). If I've set a Mastodon URL for the entry, it'll link to the Mastodon thread too. #11ty
  • Switching to Bigger Picture for the lightbox (toot) Lightbox: I replaced PhotoSwipe with Bigger Picture seems nice and flexible
  • Connections (toot) Following a link from https://manuelmoreale.com/pb-maya , I enjoyed this quote about blogging:

    Although, as well researched and as thoughtful as Houston might be there's a messiness at work here that I love; it is the true great quality of a blog. That permission to roam, to let your curiosity grab you by the lapel and hoist you across fifteen different subjects over the course of a single paragraph; blogging is pointing at things and falling in love.

  • Bull sharks and respiration (toot) My 2021 post on A list of sharks that are obligate ram ventilators continues to pop up every now and then. Someone had a question about whether bull sharks are obligate ram ventilators, so I did a little research and added whatever notes I could find there. I think maybe they aren't, although they're sometimes described as such? Not sure, maybe someone can chime in. =)
  • Programmable Notes (toot) Oooh, it could be fun to trawl through these for ideas for things to port over to Emacs.

    The Smartblocks plug-in for Roam Research is the system I personally use to build these types of workflows. It offers a set of triggers, variables, and commands you can chain together into fairly readable statements like: <%SET:topOfMindToday,<%INPUT:What's on your mind today?%>%> or <%RANDOMBLOCKFROM:Writing Ideas%>.

    Even with limited programming knowledge, many people in the community have been able to fashion their own Smartblock flows. Plenty of them have published their workflows to the community Github for others to use.

    Smartblock flows on Github

  • The promise and distraction of productivity and note-taking systems (toot)

    Books are maps to territories that are completely internal to the reader. By focusing so heavily on extracting the surface symbology of the map itself, these process-heavy note-takers risk losing sight of the territory. A book's territory is the reasoning and argument that the book presents to you as a path you take through your own psyche. The goal isn't to remember everything the book contains. Remembering a book's contents is useless. The book exists to contain what it contains. If the contents are important, you keep a copy of it for you to look things up again.

    But that isn't the point of reading. The purpose of reading is to be changed. Sometimes the change is trivial and temporary – a piece of fiction that brings some joy in your life. Sometimes the change is profound – a shift in your perspective on life. “Action items” from a book are external and forcing yourself to follow through on them is exhausting.

  • Added Pagefind search (toot) I'm also experimenting with using Pagefind to provide search for my static site using client-side Javascript. It currently analyzes 10934 files and indexes 8183 pages (87272 words) in 40 seconds. The data is 125MB, but a search for, say, "sketchnote" transfers only 280KB, so that's pretty good. I think I'm adding the date properly and I know I can set that as the default sort, but I haven't yet figured out how to make it possible for people to sort by either relevance or date as they want. I also want to eventually format the search results to include the date. Maybe Building a Pagefind UI – dee.underscore.world will be useful.
Time
Category The other week % Last week % Diff % h/wk Diff h/wk
Unpaid work 3.3 4.7 1.4 7.9 2.4
Discretionary - Productive 19.2 20.1 0.9 33.7 1.5
Personal 9.4 9.9 0.5 16.6 0.8
Discretionary - Play 1.2 1.6 0.4 2.7 0.7
Discretionary - Family 0.0 0.3 0.3 0.5 0.5
A- 31.6 31.5 -0.1 53.0 -0.1
Business 1.7 0.8 -0.9 1.3 -1.5
Sleep 33.7 31.1 -2.5 52.3 -4.3
View org source for this post

Org Mode: Cutting the current list item (including nested lists) with a speed command

| emacs, org

Defining shortcuts in org-speed-commands is handy because you can use these single-key shortcuts at the beginning of a subtree. With a little modification, they'll also work at the beginning of list items.

(defun my-org-use-speed-commands-for-headings-and-lists ()
  "Activate speed commands on list items too."
  (or (and (looking-at org-outline-regexp) (looking-back "^\**" nil))
      (save-excursion (and (looking-at (org-item-re)) (looking-back "^[ \t]*" nil)))))
(setq org-use-speed-commands 'my-org-use-speed-commands-for-headings-and-lists)

I want k to be an org-speed-commands that cuts the current subtree or list item. This is handy when I'm cleaning up the Mastodon toots in my weekly review or getting rid of outline items that I no longer need. By default, k is mapped to org-cut-subtree, but it's easy to override.

(defun my-org-cut-subtree-or-list-item (&optional n)
  "Cut current subtree or list item."
  (cond
   ((and (looking-at org-outline-regexp) (looking-back "^\**" nil))
    (org-cut-subtree n))
   ((looking-at (org-item-re))
    (kill-region (org-beginning-of-item) (org-end-of-item)))))
(with-eval-after-load 'org
  (setf (alist-get "k" org-speed-commands nil nil #'string=)
        #'my-org-cut-subtree-or-list-item))

So now, if I put my cursor before "1." below and press k:

- this
  1. is a
    - nested
  2. list
- with levels

it will turn into:

  • this
    1. list
  • with levels

You can find out a little more about Org Mode speed commands in the Org manual: (info "(org) Speed Keys").

This is part of my Emacs configuration.
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

Feline feelings

| drawing, cat

Feel free to use this under the Creative Commons Attribution License.

Text from sketch

Feline feelings sachachua.com/2025-03-26-01

  • happy
    • playful
    • content
    • interested
    • proud
    • accepted
    • powerful
    • peaceful
    • trusting
    • optimistic
  • surprised
    • startled
    • confused
    • amazed
    • excited
  • bad
    • tired
    • busy
    • stressed
    • bored
  • fearful
    • scared
    • anxious
    • weak
    • rejected
    • insecure
    • threatened
  • angry
    • let down
    • humiliated
    • bitter
    • mad
    • aggressive
    • frustrated
    • distant
    • critical
  • disgusted
    • disapproving
    • disappointed
    • awful
    • repelled
  • sad
    • lonely
    • vulnerable
    • despair
    • guilty
    • depressed
    • hurt

Feelings wheel by Geoffrey Roberts

I want to draw more expressively, and experimenting with distinguishing between emotions seems like a good start. I followed up on our idea of drawing cats after Stick figure out feelings. It was a lot of fun drawing various kitties based on Geoffrey Roberts' emotion wheel. It turns out I'm still sometimes iffy on what a cat looks like in different poses, but maybe enough of the cat-ness has come through in these little doodles. =)

Related posts:

You might also like:

View org source for this post

mastodon.el: Copy toot content as Org Mode

| mastodon, org, emacs

Sometimes I want to copy a toot and include it in my Org Mode notes, like when I post a thought and then want to flesh it out into a blog post. This code defines my-mastodon-org-copy-toot-content, which converts the toot text to Org Mode format using Pandoc and puts it in the kill ring so I can yank it somewhere else.

(defun my-mastodon-toot-at-url (&optional url)
  "Return JSON toot object at URL.
If URL is nil, return JSON toot object at point."
  (if url
      (let* ((search (format "%s/api/v2/search" mastodon-instance-url))
             (params `(("q" . ,url)
                       ("resolve" . "t"))) ; webfinger
             (response (mastodon-http--get-json search params :silent)))
        (car (alist-get 'statuses response)))
    (mastodon-toot--base-toot-or-item-json)))

(defun my-mastodon-org-copy-toot-content (&optional url)
  "Copy the current toot's content as Org Mode.
Use pandoc to convert.

When called with \\[universal-argument], prompt for a URL."
  (interactive (list
                (when current-prefix-arg
                  (read-string "URL: "))))

  (let ((toot (my-mastodon-toot-at-url url)))
    (with-temp-buffer
      (insert (alist-get 'content toot))
      (call-process-region nil nil "pandoc" t t nil "-f" "html" "-t" "org")
      (kill-new
       (concat
        (org-link-make-string
         (alist-get 'url toot)
         (concat "@" (alist-get 'acct (alist-get 'account toot))))
        ":\n\n#+begin_quote\n"
        (string-trim (buffer-string)) "\n#+end_quote\n"))
      (message "Copied."))))
This is part of my Emacs configuration.
View org source for this post

mastodon.el: Collect handles in clipboard (Emacs kill ring)

| mastodon, emacs

I sometimes want to thank a bunch of people for contributing to a Mastodon conversation. The following code lets me collect handles in a single kill ring entry by calling it with my point over a handle or a toot, or with an active region.

(defvar my-mastodon-handle "@sacha@social.sachachua.com")
(defun my-mastodon-copy-handle (&optional start-new beg end)
  "Append Mastodon handles to the kill ring.

Use the handle at point or the author of the toot.  If called with a
region, collect all handles in the region.

Append to the current kill if it starts with @. If not, start a new
kill. Call with \\[universal-argument] to always start a new list.

Omit my own handle, as specified in `my-mastodon-handle'."
  (interactive (list current-prefix-arg
                     (when (region-active-p) (region-beginning))
                     (when (region-active-p) (region-end))))
  (let ((handle
         (if (and beg end)
             ;; collect handles in region
             (save-excursion
               (goto-char beg)
               (let (list)
                 ;; Collect all handles from the specified region
                 (while (< (point) end)
                   (let ((mastodon-handle (get-text-property (point) 'mastodon-handle))
                         (button (get-text-property (point) 'button)))
                     (cond
                      (mastodon-handle
                       (when (and (string-match "@" mastodon-handle)
                                  (or (null my-mastodon-handle)
                                      (not (string= my-mastodon-handle mastodon-handle))))
                         (cl-pushnew
                          (concat (if (string-match "^@" mastodon-handle) ""
                                    "@")
                                  mastodon-handle)
                          list
                          :test #'string=))
                       (goto-char (next-single-property-change (point) 'mastodon-handle nil end)))
                      ((and button (looking-at "@"))
                       (let ((text-start (point))
                             (text-end (or (next-single-property-change (point) 'button nil end) end)))
                         (dolist (h (split-string (buffer-substring-no-properties text-start text-end) ", \n\t"))
                           (unless (and my-mastodon-handle (string= my-mastodon-handle h))
                             (cl-pushnew h list :test #'string=)))
                         (goto-char text-end)))
                      (t
                       ;; collect authors of toots too
                       (when-let*
                           ((toot (mastodon-toot--base-toot-or-item-json))
                            (author (and toot
                                         (concat "@"
                                                 (alist-get
                                                  'acct
                                                  (alist-get 'account (mastodon-toot--base-toot-or-item-json)))))))
                         (unless (and my-mastodon-handle (string= my-mastodon-handle author))
                           (cl-pushnew
                            author
                            list
                            :test #'string=)))
                       (goto-char (next-property-change (point) nil end))))))
                 (setq handle (string-join (seq-uniq list #'string=) " "))))
           (concat "@"
                   (or
                    (get-text-property (point) 'mastodon-handle)
                    (alist-get
                     'acct
                     (alist-get 'account (mastodon-toot--base-toot-or-item-json))))))))
    (if (or start-new (null kill-ring) (not (string-match "^@" (car kill-ring))))
        (kill-new handle)
      (dolist (h (split-string handle " "))
        (unless (member h (split-string " " (car kill-ring)))
          (setf (car kill-ring) (concat (car kill-ring) " " h)))))
    (message "%s" (car kill-ring))))

Another perk of tooting from Emacs using mastodon.el. =)

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