Posting blogs as Mastodon Toots

What is better than posting a blog post? Posting about your posting pipeline. I did this previously with Twitter.

the elephant in the room

mastodon.social does not support any formatting in the status posts. Yes, there are other instances which have patches to enable features such as markdown formatting, but there is no upstream support.

time to code

My website is built using a really simple static site generator I wrote in Python. Therefore, each post is self-contained in a Markdown file with the necessary metadata.

I am going to specify the path to the blog post, parse it and then publish it.

I initially planned on having a command line parser and some more flags.

interacting with mastodon

I ended up using mastodon.py rather than crafting requests by hand. Each statuspost/toot call returns a statusid that can be then used as an inreplyto parameter.

For the code snippets, seeing that mastodon does not support native formatting, I am resorting to using ray-so.

reading markdown

I am using a bunch of regex hacks, and reading the blog post line by line. Because there is no markdown support, I append all the links to the end of the toot. For images, I upload them and attach them to the toot. The initial toot is generated based off the title and the tags associated with the post.

# Regexes I am using

markdown_image = r'(?:!\[(.*?)\]\((.*?)\))'
markdown_links = r'(?:\[(.*?)\]\((.*?)\))'
tags_within_metadata = r"tags: ([\w,\s]+)"
metadata_regex = r"---\s*\n(.*?)\n---\s*\n"

This is useful when I want to get the exact data I want. In this case, I can extract the tags from the front matter.

metadata = re.search(metadata_regex, markdown_content, re.DOTALL)
if metadata:
    tags_match = re.search(r"tags: ([\w,\s]+)", metadata.group(1))
    if tags_match:
        tags = tags_match.group(1).split(",")

code snippet support

I am running akashrchandran/Rayso-API.

import requests

def get_image(code, language: str = "python", title: str = "Code Snippet"):
    params = (
        ('code', code),
        ('language', language),
        ('title', title),
    )

    response = requests.get('http://localhost:3000/api', params=params)

    return response.content

threads! threads! threads!

Even though mastodon does officially have a higher character limit than Twitter. I prefer the way threads look.

result

Everything does seem to work! Seeing that you are reading this on Mastodon, and that I have updated this section.

what's next?

Here is the current code:

from mastodon import Mastodon
from mastodon.errors import MastodonAPIError
import requests
import re

mastodon = Mastodon(
    access_token='reeeeee',
    api_base_url="https://mastodon.social"
    )

url_base = "https://web.navan.dev"
sample_markdown_file = "Content/posts/2022-12-25-blog-to-toot.md"

tags = []
toots = []
image_idx = 0
markdown_image = r'(?:!\[(.*?)\]\((.*?)\))'
markdown_links = r'(?:\[(.*?)\]\((.*?)\))'

def get_image(code, language: str = "python", title: str = "Code Snippet"):
    params = (
        ('code', code),
        ('language', language),
        ('title', title),
    )

    response = requests.get('http://localhost:3000/api', params=params)

    return response.content

class TootContent:
    def __init__(self, text: str = ""):
        self.text = text
        self.images = []
        self.links = []
        self.image_count = len(images)

    def __str__(self):
        toot_text = self.text
        for link in self.links:
            toot_text += " " + link
        return toot_text

    def get_text(self):
        toot_text = self.text
        for link in self.links:
            toot_text += " " + link
        return toot_text

    def get_length(self):
        length = len(self.text)
        for link in self.links:
            length += 23
        return length

    def add_link(self, link):
        if len(self.text) + 23 < 498:
            if link[0].lower() != 'h':
                link = url_base + link
            self.links.append(link)
            return True
        return False

    def add_image(self, image):

        if len(self.images) == 4:
            # will handle in future
            print("cannot upload more than 4 images per toot") 
            exit(1)
        # upload image and get id
        self.images.append(image)
        self.image_count = len(self.images)

    def add_text(self, text):
        if len(self.text + text) > 400:
            return False
        else:
            self.text += f" {text}"
            return True

    def get_links(self):
        print(len(self.links))


in_metadata = False
in_code_block = False

my_toots = []
text = ""
images = []
image_links = []
extra_links = []
tags = []

code_block = ""
language = "bash"

current_toot = TootContent()

metadata_regex = r"---\s*\n(.*?)\n---\s*\n"


with open(sample_markdown_file) as f:
    markdown_content = f.read()


metadata = re.search(metadata_regex, markdown_content, re.DOTALL)
if metadata:
    tags_match = re.search(r"tags: ([\w,\s]+)", metadata.group(1))
    if tags_match:
        tags = tags_match.group(1).split(",")


markdown_content = markdown_content.rsplit("---\n",1)[-1].strip()

for line in markdown_content.split("\n"):
    if current_toot.get_length() < 400:
        if line.strip() == '':
            continue
        if line[0] == '#':
            line = line.replace("#","".strip())
            if len(my_toots) == 0:
                current_toot.add_text(
                    f"{line}: a cross-posted blog post \n"
                    )
                hashtags = ""
                for tag in tags:
                    hashtags += f"#{tag.strip()},"
                current_toot.add_text(hashtags[:-1])
                my_toots.append(current_toot)
                current_toot = TootContent()
            else:
                my_toots.append(current_toot)
                current_toot = TootContent(text=f"{line.title()}:")
            continue
        else:
            if "```" in line:
                in_code_block = not in_code_block
                if in_code_block:
                    language = line.strip().replace("```",'')
                    continue
                else:
                    with open(f"code-snipped_{image_idx}.png","wb") as f:
                        f.write(get_image(code_block, language))
                    current_toot.add_image(f"code-snipped_{image_idx}.png")
                    image_idx += 1
                    code_block = ""
                continue
            if in_code_block:
                line = line.replace("   ","\t")
                code_block += line + "\n"
                continue
            if len(re.findall(markdown_image,line)) > 0:
                for image_link in re.findall(markdown_links, line):
                    image_link.append(image_link[1])
                    # not handled yet
                line = re.sub(markdown_image,"",line)
            if len(re.findall(markdown_links,line)) > 0:
                for link in re.findall(markdown_links, line):
                    if not (current_toot.add_link(link[1])):
                        extra_links.append(link[1])
                    line = line.replace(f'[{link[0]}]({link[1]})',link[0])
            if not current_toot.add_text(line):
                my_toots.append(current_toot)
                current_toot = TootContent(line)
    else:
        my_toots.append(current_toot)
        current_toot = TootContent()

my_toots.append(current_toot)

in_reply_to_id = None
for toot in my_toots:
    image_ids = []
    for image in toot.images:
        print(f"uploading image, {image}")
        try:
            image_id = mastodon.media_post(image)
            image_ids.append(image_id.id)
        except MastodonAPIError:
            print("failed to upload. Continuing...")
    if image_ids == []:
        image_ids = None

    in_reply_to_id = mastodon.status_post(
        toot.get_text(), in_reply_to_id=in_reply_to_id, media_ids=image_ids
        ).id

Not the best thing I have ever written, but it works!

If you have scrolled this far, consider subscribing to my mailing list here. You can subscribe to either a specific type of post you are interested in, or subscribe to everything with the "Everything" list.