Categories: 11ty

RSS - Atom - Subscribe via email

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 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…

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

Added a gallery and slideshow view for my visual book notes

| 11ty, blogging

I customized my visual book notes - all view to show the thumbnails of the images, and I added You can get to it by going to the visual-book-notes category from a post and then choosing "All". I like the new "View slideshow" and "Shuffle slideshow" buttons I added.

2024-10-23_14-09-26.png
Figure 1: Screenshot of my visual book notes gallery

I also fixed some of the broken images in older posts, so there should be 43 posts with images now.

Someday I want to add a way to go from the sketch in the slideshow to the post, but it might require upgrading the version of Photoswipe I have. I'm currently on 4.x, which hasn't been updated in years.

View org source for this post

Adding an XSL stylesheet for my RSS and Atom feeds

| 11ty, geek, blogging

Inspired by the styling on Susam's blog feed, I followed this tutorial on using XML stylesheets and added XSL stylesheets for my RSS and Atom feeds. I have RSS and Atom feeds for all my posts as well as for each category or tag (ex: emacs).

2024-01-13_19-06-02.png
Figure 1: RSS feed after styling

To make that happen, I added a line like this to my RSS template:

<?xml-stylesheet href="/assets/rss.xsl" type="text/xsl"?>

and for my Atom template:

<?xml-stylesheet href="/assets/atom.xsl" type="text/xsl"?>

and those refer to:

rss.xsl
<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="3.0"
                xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                xmlns:dc="http://purl.org/dc/elements/1.1/"        xmlns:atom="http://www.w3.org/2005/Atom">
  <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
  <xsl:template match="/">
  <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
    <head>
      <title>
        RSS Feed | <xsl:value-of select="/rss/channel/title"/>
      </title>
      <link rel="stylesheet" href="/assets/style.css"/>
    </head>
    <body>
      <h1 style="margin-bottom:0">Recent posts: <xsl:value-of select="/rss/channel/title"/></h1>
      <p>
        This is an RSS feed. You can subscribe to <a href=" {/rss/channel/link}"><xsl:value-of select="/rss/channel/link"/></a> in a feed reader such as <a href="https://github.com/skeeto/elfeed">Elfeed</a> for Emacs, <a href="https://www.inoreader.com/">Inoreader</a>, or <a href="https://newsblur.com/">NewsBlur</a>, or you can use tools like <a href="https://github.com/rss2email/rss2email">rss2email</a>. The feed includes the full blog posts.
You can also view the posts on the website at
<a href="{/rss/channel/atom:link[contains(@rel,'alternate')]/@href}"><xsl:value-of select="/rss/channel/atom:link[contains(@rel,'alternate')]/@href" /></a> .
      </p>
      <xsl:for-each select="/rss/channel/item">
        <div style="margin-bottom:20px">
        <div>
          <xsl:value-of select="pubDate" />
        </div>
        <div>
          <a>
            <xsl:attribute name="href">
              <xsl:value-of select="link/@href"/>
            </xsl:attribute>
            <xsl:value-of select="title"/>
          </a></div></div>
      </xsl:for-each>
    </body>
    </html>
  </xsl:template>
</xsl:stylesheet>
atom.xsl
<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="3.0"
                xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                xmlns:atom="http://www.w3.org/2005/Atom">
  <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
  <xsl:template match="/">
  <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
    <head>
      <title>
        Atom Feed | <xsl:value-of select="/atom:feed/atom:title"/>
      </title>
      <link rel="stylesheet" href="/assets/style.css"/>
    </head>
    <body>
      <h1 style="margin-bottom:0">Recent posts: <xsl:value-of select="/atom:feed/atom:title"/></h1>
      <p>
        This is an Atom feed. You can subscribe to <a href=" {/atom:feed/atom:link/@href}"><xsl:value-of select="/atom:feed/atom:link/@href"/></a> in a feed reader such as <a href="https://github.com/skeeto/elfeed">Elfeed</a> for Emacs, <a href="https://www.inoreader.com/">Inoreader</a>, or <a href="https://newsblur.com/">NewsBlur</a>, or you can use tools like <a href="https://github.com/rss2email/rss2email">rss2email</a>. The feed includes the full blog posts.
You can also view the posts on the website at
<a href="{/atom:feed/atom:link[contains(@rel,'alternate')]/@href}"><xsl:value-of select="/atom:feed/atom:link[contains(@rel,'alternate')]/@href" /></a> .
      </p>
      <xsl:for-each select="/atom:feed/atom:entry">
        <div style="margin-bottom:20px">
          <div>
          <xsl:value-of select="substring(atom:updated, 0, 11)" /></div>
          <div><a>
            <xsl:attribute name="href">
              <xsl:value-of select="atom:link/@href"/>
            </xsl:attribute>
            <xsl:value-of select="atom:title"/>
          </a></div></div>
      </xsl:for-each>
    </body>
    </html>
  </xsl:template>
</xsl:stylesheet>
View org source for this post

Fixing my old ambiguous sketch references

| blogging, 11ty, emacs

At some point during the conversion of my blog from Wordpress to 11ty, I wanted to change my sketch links to use a custom shortcode instead of referring to the sketch in my old wp-uploads directory. Because Wordpress changed the filenames a little, I used the ID at the start of the filename. I forgot that many of my filenames from 2013 to 2015 just had the date without a uniquely identifying letter or number suffix, so many old references were ambiguous and my static site generator just linked to the first matching file. When I was listening to my old monthly reviews as part of my upcoming 10-year review, I noticed the repeated links. So I wrote these functions to help me find and replace markup of the form sketchLink "2013-10-06" with sketchLink "2013-10-06 Daily drawing - thinking on paper #drawing", replacing references to the same date with the next sketch in the list. I figured that would be enough to get the basic use case sorted out (usually a list of sketches in my monthly/weekly reviews), taking advantage of the my-list-sketches function I defined in my Emacs config.

(defun my-replace-duplicate-sketch-list-references ()
  (interactive)
  (goto-char (point-min))
  (let (seen)
    (while (re-search-forward "sketchLink \\\"\\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\\)\\\""
                              nil t)
      (if (assoc (match-string 1) seen)
          (setcdr (assoc (match-string 1) seen) (1+ (assoc-default (match-string 1) seen)))
        (setq seen (cons (cons (match-string 1) 1) seen))))
    (mapc (lambda (entry)
            (goto-char (point-min))
            (mapc (lambda (sketch)
                    (if (re-search-forward (format "sketchLink \\\"\\(%s\\)\\\""
                                                   (regexp-quote (car entry))) nil t)
                        (replace-match (save-match-data (file-name-sans-extension sketch))
                                       nil t nil 1)
                      (message "Skipping %s possible ref to %s"
                               (buffer-file-name)
                               sketch)))
                  (my-list-sketches (concat "^" (regexp-quote (car entry))) nil '("~/sync/sketches"))))
          seen)))

Sometimes I needed to delete the whole list and start again:

(defun my-insert-sketch-list-between (start-date end-date)
  (insert
   (mapconcat
    (lambda (f)
      (format "<li>%s sketchLink \"%s\" %s</li>\n"
              (concat "{" "%")  ; avoid confusing 11ty when I export this
              (file-name-sans-extension f)
              (concat "%" "}")))
    (sort (seq-filter
           (lambda (f) (and (string< f end-date) (not (string< f start-date))))
           (my-list-sketches nil nil '("~/sync/sketches")))
          'string<)
    "")))

I used find-grep-dired to search for sketchLink \"[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\" and then I just used a keyboard macro to process each file.

Anyway, really old monthly reviews like this one for October 2013 should mostly make sense again. I could probably pull out the correct references from the Wordpress database backup, but what I've got is probably okay. I would probably have gotten much grumpier trying to do this without Emacs Lisp. Yay Emacs!

View org source for this post

Moving my Org post subtree to the 11ty directory

| 11ty, org, emacs, blogging

I sometimes want to move the Org source for my blog posts to the same directory as the 11ty-exported HTML. This should make it easier to update and reexport blog posts in the future. The following code copies or moves the subtree to the 11ty export directory.

(defun my-org-11ty-copy-subtree (&optional do-cut)
  "Copy the subtree for the current post to the 11ty export directory.
With prefix arg, move the subtree."
  (interactive (list current-prefix-arg))
  (let* ((file-properties
          (org-element-map
              (org-element-parse-buffer)
              'keyword
            (lambda (el)
              (when (string-match "ELEVENTY" (org-element-property :key el))
                (list
                 (org-element-property :key el)
                 (org-element-property :value el)
                 (buffer-substring-no-properties
                  (org-element-property :begin el)
                  (org-element-property :end el)))))))
         (entry-properties (org-entry-properties))
         (filename (expand-file-name
                    "index.org"
                    (expand-file-name
                     (assoc-default "EXPORT_ELEVENTY_FILE_NAME" entry-properties)
                     (car (assoc-default "ELEVENTY_BASE_DIR" file-properties))))))
    (unless (file-directory-p (file-name-directory filename))
      (make-directory (file-name-directory filename) t))
    ;; find the heading that sets the current EXPORT_ELEVENTY_FILE_NAME
    (goto-char
     (org-find-property "EXPORT_ELEVENTY_FILE_NAME" (org-entry-get-with-inheritance "EXPORT_ELEVENTY_FILE_NAME")))
    (org-copy-subtree 1 (if do-cut 'cut))
    (with-temp-file filename
      (org-mode)
      (insert (or
               (mapconcat (lambda (file-prop) (elt file-prop 2))
                          file-properties
                          "")
               "")
              "\n")
      (org-yank))
    (find-file filename)
    (goto-char (point-min))))

Then this adds a link to it:

(defun my-org-export-filter-body-add-index-link (string backend info)
  (if (and
       (member backend '(11ty html))
       (plist-get info :file-name)
       (plist-get info :base-dir)
       (file-exists-p (expand-file-name
                       "index.org"
                       (expand-file-name
                        (plist-get info :file-name)
                        (plist-get info :base-dir)))))
      (concat string
              (format "<div><a href=\"%sindex.org\">View org source for this post</a></div>"
                      (plist-get info :permalink)))
    string))

(with-eval-after-load 'ox
  (add-to-list 'org-export-filter-body-functions #'my-org-export-filter-body-add-index-link))

Then I want to wrap the whole thing up in an export function:

(defun my-org-11ty-export (&optional async subtreep visible-only body-only ext-plist)
  (let* ((info (org-11ty--get-info subtreep visible-only))
         (file (org-11ty--base-file-name subtreep visible-only)))
    (unless (string= (plist-get info :input-file)
                     (expand-file-name
                      "index.org"
                      (expand-file-name
                       (plist-get info :file-name)
                       (plist-get info :base-dir))))
      (save-window-excursion
        (my-org-11ty-copy-subtree)))
    (org-11ty-export-to-11tydata-and-html async subtreep visible-only body-only ext-plist)
    (my-org-11ty-find-file)))

Now to figure out how to override the export menu. Totally messy hack!

(with-eval-after-load 'ox-11ty
  (map-put (caddr (org-export-backend-menu (org-export-get-backend '11ty)))
           ?o (list "To Org, 11tydata.json, HTML" 'my-org-11ty-export)))
View org source for this post
This is part of my Emacs configuration.