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

2025-03-24 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

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