Custom CLI tool to link Mastodon posts and blog posts
Code in this blog post was written with versions: mastodon: 4.3.8, Node: 23.3.0

Two years ago, I built a way to add comments to my blog posts via Mastodon replies and I’ve used it here and there but not super actively. This summer, I realised one reason I haven’t been super actively using it is that the workflow wasn’t enjoyable.
Until now, when I wanted to enable comments for a post, I would
- Publish the blog post
- Post about it in Mastodon
- Copy the ID from the post
- Open the blog post in code editor
- Add ID to post’s frontmatter
- Push and deploy changes
To see if reducing that friction makes a change, I built a custom command line tool to manage this and now the flow is
- Publish the blog post
- Post about it in Mastodon
-
Copy-paste post URL to
npm run mastodon
command - Publish changes
How it’s done
Since my website is built with Eleventy which is a Javascript tool, I tend to build my website tools with Javascript as well so this command line tool is a Javascript script too.
The design of this tool is a great example of situated software built to solve one problem. It relies on very specific inputs (for example, it expects and only works with Mastodon posts in mastodon.world instance) and relies heavily on how my Eleventy website is set up. I’m sharing it to show how I did it and to provide inspiration and ideas so you can build something similar to your toolkit.
The CLI interface is built with Commander.js that I’ve learned to enjoy as its API is easy to read and it’s handy for building quick-and-simple interfaces like this one and more complex ones with multiple sub commands.
const fs = require("fs");
const fm = require("front-matter");
const yesno = require("yesno");
const yaml = require("js-yaml");
const { Command } = require("commander");
const { parse } = require("node-html-parser");
const POST_FOLDER = "posts";
const program = new Command();
program
.name("mastodon-cli")
.description("CLI to connect Mastodon posts to blog posts for comments")
.version("0.8.0")
.argument("<mastodon_url>", "URL to Mastodon post")
.action(async (mastodon_url) => {
const mastodonPostId = extractMastodonId(mastodon_url);
const links = await getBlogPostLinks(mastodonPostId);
const posts = getPosts(links);
posts.forEach(async (post) => {
const filename = getFile(post);
if (!filename) {
return;
}
const ok = await yesno({
question: `Do you want to write id ${mastodonPostId} to ${process.cwd()}/${filename}?`,
});
if (!ok) {
return;
}
writeFile(filename, mastodonPostId);
});
});
program.parse();
Main flow looks like this:
-
Run
npm run mastodon [url]
- Parse ID from the URL
- Find any links from the post to my blog posts
- For each post, get the corresponding file and ask if I want to add this ID to that post
- Add the link and rewrite the file
function extractMastodonId(mastodonUrl) {
return mastodonUrl.split("/").slice(-1)[0];
}
To extract the post id from an URL, I split it by
/
and take the last section.
async function getBlogPostLinks(mastodonPostId) {
const originalPost = await fetch(
`https://mastodon.world/api/v1/statuses/${mastodonPostId}`
);
const originalData = await originalPost.json();
const dom = parse(originalData.content);
const linkNodes = dom.querySelectorAll(
'a[href^="https://hamatti.org/posts/"]'
);
const links = linkNodes.map((link) => link.attributes.href);
return links;
}
To find all the links to my blog from the post, I fetch its information from the API, parse the post’s HTML into a DOM representation and find all links that point to my blog. CSS attribute selectors are just wonderful.
function getPosts(links) {
return links.map((link) => {
return link
.split("/")
.filter((link) => link)
.at(-1);
});
}
I then take all the links and extract the slugs as those (almost always) match
the filenames in my filesystem. I go through every link, split by
/
, get rid of the empty parts (for
example, if a link ends with a trailing slash like in
/posts/slug-to-my-post/
, I get rid of
the empty one after the last slash. Then I pick the last item from the split
array.
function getFile(slug) {
let filename = null;
if (fs.existsSync(`${POST_FOLDER}/${slug}.njk`)) {
filename = `${POST_FOLDER}/${slug}.njk`;
} else if (fs.existsSync(`${POST_FOLDER}/${slug}.md`)) {
filename = `${POST_FOLDER}/${slug}.md`;
}
return filename;
}
For legacy reasons, I have blog posts both in Markdown (.md
) and Nunjucks (.njk
) formats so I need
to check if either exists.
function writeFile(filename, mastodonPostId) {
const file = fs.readFileSync(filename, {
encoding: "utf-8",
});
const frontmatter = fm(file);
frontmatter.attributes.mastodon_id = mastodonPostId;
let yamlFrontmatter = yaml.dump(frontmatter.attributes, {
forceQuotes: true,
quotingType: '"',
});
fs.writeFileSync(
filename,
`---
${yamlFrontmatter}---
${frontmatter.body}`
);
}
To write a file, I first read it from filesystem and then using
front-matter package
parse it to frontmatter and body. I change the
mastodon_id
field in the frontmatter to
the post id and write it and the post body back into the file.
For the very few edge cases where the slug doesn’t match the filename (for example, if I’ve used permalinks), I can do it manually. Those are less than 1% of posts anyway and I already know it up front if I’ve decided to do so so I don’t have to build that functionality into the tool.
A cool feature is that if I’ve linked to multiple blog posts in the same Mastodon post, the tool asks me separately for each post if I want to link them.
If something above resonated with you, let's start a discussion about it! Email me at juhamattisantala at gmail dot com and share your thoughts. In 2025, I want to have more deeper discussions with people from around the world and I'd love if you'd be part of that.