Mastodon Posting and JS-free Readability
I've finally crossed it off my to-do list: if you have Javascript enabled, you can now comment on this blog with a Mastodon account! If you don't have Javascript enabled, you won't see comments, but you will now see math and diagram renderings (as opposed to LaTeX and MermaidJS code blocks).
This post documents what I did and my justifications/reasoning.
Mastodon comments
This blog is now linked to my professional Fediverse microblogging account on hci.social. I chose the hci.social instance since much of my work is human-factors adjacent, even if I never publish in those venues myself. Consequently, I figured I'd have more fruitful conversations about the end-user-facing parts of my work over there, rather than on discuss.systems or types.pl. Of course, because it's the Fediverse, those communities can still see the posts; it's just that the number of (social) network hops needed is slightly longer for those other instances.
To be clear: my blog is not itself running Mastodon and you cannot reply to messages from within my blog domain. My requirements were similar to those outlined Carl Schwan's blog post and I largely echoed the design decisions therein.
Some context/motivation
I've always been meh on social media for promoting research — it feels too much like advertising/hustling, which isn't my thing. The folks I tend to follow use it for discussion, rather than promotion, which I prefer in principle, but there's something about the affordances of microblogging that makes it hard for me to find the appropriate level of engagement. As a result, I tend to use microblogging for lurking and shitposting.
Instead, I've always preferred the long(er)form nature of blogging and commenting. As I wrote in 2014:
Before the internet, academics and intellectuals shared their musings and worked out problems via letter writing...I see today’s blogs as an extension of that letter-writing tradition.
To make blogging truly worth it, it helps to have comments. I’ve noticed that some graduate students turn off comments in their blogs and prefer to hold the conversation over email. I welcome comments over email, but would prefer if they happened in the open, where others can participate, if they wish.
In the 2021-2022 academic year I stopped supporting commenting in my various blogs. This was largely due to changing technology: I started on a University-supported Wordpress blog before moving to a static site hosted on Github.1 Upon moving to Github, I set up Disqus for comments. Years later, when I began incorporating blogging into my classroom, I became concerned about its privacy story. My temporary solution was no comments and a consent form to disclose Github identities — I had planned to work on better infrastructure support in my next course that had blogging, but I left teaching before that happened.
Now I'd like to re-instate comments with the main requirement that they not rely on a proprietary software service. I'd also like a solution that doesn't immediately require that I manage my own web server — right now this website is being hosted out on a Microsoft server, through Github pages, so I'm looking for a more incremental solution.
How it works
As I mentioned above, the basic workflow mirrors Carl Schwan's blog post. I'm going to supply some details here, since this solution is for a static blog written with mkdocs, rather than hugo.
One-time actions:
- Generate an API token for the posting account. I saved this token in a read-only local file that doesn't get added to source control.
- Write a mkdocs template to override the comments HTML. The template name is
comments.htmland it lives in theoverrides/partialsfolder. (Comments template) - Edit
mkdocs.yml:- Add the
custom_diroption to the theme and the appropriate value. For me the top-level of the YAML file looked like this:
... theme: custom_dir: src/overrides ... ... - Add the top level domain of the Mastodon instance. For me the top-level of the YAML file looked like this:
... extra: mastodon: https://hci.social ...
- Add the
- Write a script that:
- Makes an initial post to the account to obtain a status id.
- Add the status id to the blog post.
- Extracts the teaser text from the blog post and updates the Mastodon status post.
Better support for users who have Javascript turned off
I decided that since Mastodon doesn't work with Javascript off, it would be acceptable for my blog to operate similarly. However, I'd like to support people's abilty to read the post without having Javascript on. Right now my blog uses two Javascript packages: MermaidJS for rendering graphs and MathJax for rendering LaTeX math mode. Since both are supported out of the box by mkdocs, I figured there might be a configuration option to pre-compile to svg and mathml respectively — after all, both have CLIs.
Nope!
I then went looking for existing plugins and found some AI slop, some overly bloated options, and some that did not give me much trust that they weren't spyware. What I want to do is really quite simple: identify the mermaid and math mode blocks, send them to their respective CLIs and if the process returns successfully, replace the blocks of markdown with the appropriate SVG or MathML code. Consequently, I ended up writing my own plugin, which I simply installed locally.
I followed the instructions for building plugins and only needed to override the on_page_markdown hook. I had to deal with the usual annoying parsing minutae, but over all the process went as you'd expect.
The instructions were a bit dated vis a vis modern build tools; here's my directory structure and pyproject.toml file:
mkdocs-nojs
__init__.py
pyproject.html
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "mkdocs_nojs"
version = "0.0.1"
[project.entry-points."mkdocs.plugins"]
nomermaid = "mkdocs_nojs:NoMermaidJs"
nomathjax = "mkdocs_nojs:NoMathJax"
Longer code snippets
Comments template
{% if page.meta.mastodon %}
{% set masto_url = config.extra.mastodon %}
{% set post_id = page.meta.mastodon %}
<h2 id="__comments">{{ lang.t("meta.comments") }}</h2>
<hr/>
<div id="replies"></div>
<script>
// https://docs.joinmastodon.org/methods/statuses/#context
// GET /api/v1/statuses/:id/context HTTP/1.1
function filterReplies(replies) {
return replies.filter(post => post.visibility == "public")
}
var getResponses = async function (base_url, post_id) {
try {
await fetch(base_url + `/api/v1/statuses/${post_id}/context`).then(response => {
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
let comments_elt = document.getElementById("replies");
let parser = new DOMParser();
response.json().then(json => {
// sort/order comments
let public_comments = filterReplies(json.descendants);
if (public_comments.length) {
for (const response of public_comments) {
// Create document object for comments and append to the parent element
}
} else {
// Display a message about not having any comments yet.
}
});
});
} catch (error) {
console.error(error.message);
}
};
getResponses("{{ masto_url }}", "{{ post_id }}");
</script>
<noscript><p>Comments require Javascript be enabled. No other functionality should be impacted. </p></noscript>
{% endif %}
Share script
#! /usr/bin/env python
from mastodon import Mastodon
import yaml
from dataclasses import dataclass
import pandoc
# Begin code generated from Google's LLM search engine auto response whatever
import requests
import readline
import rlcompleter
import os
import glob
def complete_path(text, state):
"""Custom completer for filenames and directories."""
# Expand tilde (~) to the user's home directory
if '~' in text:
text = os.path.expanduser('~')
# Add a trailing slash to directories for better completion
if os.path.isdir(text):
text += '/'
# Use glob to find all files/directories starting with the input text
# The state argument is used by readline to iterate through matches
matches = glob.glob(text + '*')
# Return the match for the current state, or None if no more matches
try:
return matches[state]
except IndexError:
return None
try:
# etosch edit: THIS LINE FROM A DIFFERENT SOURCE (usual stack overflow)
readline.set_completer_delims(' \t\n=')
# Set the custom completer function
readline.set_completer(complete_path)
# Configure the 'tab' key to use the completer
readline.parse_and_bind("tab: complete")
except ImportError:
print("Readline module not available (e.g., likely on Windows without pyreadline3). Filename completion disabled.")
# End Google LLM generated code
@dataclass
class Post():
frontmatter : dict
body : str
def __str__(self):
return f"---\n{yaml.dump(self.frontmatter)}---\n{self.body}"
def get_teaser(self):
md = self.body[:self.body.index("<!-- more -->")]
doc = pandoc.read(source=md, format='markdown')
return pandoc.write(doc, format='plain', options=['--wrap=none'])
def post_path(post : Post) -> str:
date = post.frontmatter['date']
title = post.frontmatter['title']
return str(date).replace("-", "/") + "/" + title.lower().replace("-", "_").replace(" ", "-")
def post_yaml(filepath) -> Post:
metadata = []
begin = True
with open(filepath, 'r') as f:
while line := f.readline():
match (begin, line.strip() == "---"):
# Still seeking the frontmatter start token
case (True, False): pass
# Found the frontmatter start token
case (True, True):
begin = False
# Hit the frontmatter end token
case (False, True):
return Post(frontmatter=yaml.safe_load("\n".join(metadata)),
body=f.read())
# Within the frontmatter
case (False, False):
metadata.append(line)
def init_masto_object() -> Mastodon:
# Need to make a new post
headers = {}
with open(<PATH_TO_WHERE_YOUR_ACCESS_TOKEN_LIVES>, 'r') as f:
ACCESS_TOKEN = f.read()
headers["Authorization"] = f"Bearer {ACCESS_TOKEN}"
return Mastodon(
api_base_url = <YOUR_MASTODON_INSTANCE_URL>,
access_token = ACCESS_TOKEN)
def get_mastodon_id(post, mastodon = None) -> int:
if 'mastodon' in post.frontmatter:
return post.frontmatter.mastodon
# Get post id. Set to be unlisted with some placeholder text
# <-- Can't change visibility once set, need to set to public.
mastodon = mastodon or init_masto_object()
data = mastodon.status_post('Placeholder post', visibility='public')
return str(data.id)
markdown_file = input("Please provide the file path of the post you would like to share:\n")
post = post_yaml(markdown_file)
mastodon_id = get_mastodon_id(post)
# Update the front matter of this post to have the correct Mastodon id
post.frontmatter['mastodon'] = mastodon_id.strip()
# Update the post file
with open(markdown_file, 'w') as f:
f.write(str(post))
# Update the mastodon post
init_masto_object().status_update(id=mastodon_id,
spoiler_text=f"Blog post: {post.frontmatter['title']}",
status=f"{post.get_teaser()}\nContinue reading: <YOUR_BLOG_BASE_URL>/{post_path(post)}")
-
I'd note that the University-supported Wordpress blog is now dead, which is why the link to the source for my 2014 comment about commenting points to a post from 2017, where I quote that (now dead) 2014 post. ↩