Photo of DeepakNess DeepakNess

How I Sync Mastodon Posts with My 11ty Blog

I don't have a comment system on my blog, so I started adding this comment via email almost a year ago, and received a lot of heartwarming replies through that option. But not everyone wants to email you, so I recently also started adding this "Comment on Mastodon" feature below my blog posts (along with webmentions).

Comment on Mastodon link in the footer

Typically, here's the process that I would follow:

  1. Publish a blog post
  2. Share it on Mastodon, and
  3. Add Mastodon post link in Markdown frontmatter

When you add the mastodon YAML frontmatter in a post, the "Comment on Mastodon" option automatically starts showing below the post. Here's what the frontmatter looks like:

---
date: 2025-01-01
title: "title"
description: "description"
mastodon: "MASTODON_POST_URL_HERE"
tags:
- tag-one
---

But... it's a tedious process to manually do it each time, isn't it?

I wanted something semi-automated, and I have finally put together a workflow that works for me.

  1. I publish a blog post.
  2. It's automatically published to Mastodon, Threads, and Bluesky via n8n by using Typefully API (see how it's done).
  3. Run a script to auto-fetch, find your Mastodon posts with your blog URLs and update the frontmatter.

I already use Typefully to schedule my posts on social media, so it's not an additional purchase for me. But you can automate publishing to Mastodon directly via their API.

I am going to paste the entire script that I use for this, you can save it inside, say, /scripts/mastodon.js file.

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
const CONTENT_DIR = path.join(ROOT_DIR, 'content');

const MASTODON_INSTANCE = 'https://mastodon.social';
const MASTODON_USERNAME = 'deepakness';
const BLOG_DOMAIN = 'deepakness.com';

const DRY_RUN = process.argv.includes('--dry-run');
const FORCE = process.argv.includes('--force');

// Fetch account ID from username
async function fetchAccountId() {
  const url = `${MASTODON_INSTANCE}/api/v1/accounts/lookup?acct=${MASTODON_USERNAME}`;
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch account: ${response.status}`);
  }
  const account = await response.json();
  return account.id;
}

// Fetch all statuses with pagination
async function fetchAllStatuses(accountId) {
  const statuses = [];
  let maxId = null;
  let page = 1;

  while (true) {
    const url = new URL(`${MASTODON_INSTANCE}/api/v1/accounts/${accountId}/statuses`);
    url.searchParams.set('limit', '40');
    url.searchParams.set('exclude_reblogs', 'true');
    if (maxId) {
      url.searchParams.set('max_id', maxId);
    }

    console.log(`Fetching page ${page}...`);
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Failed to fetch statuses: ${response.status}`);
    }

    const batch = await response.json();
    if (batch.length === 0) break;

    statuses.push(...batch);
    maxId = batch[batch.length - 1].id;
    page++;

    // Small delay to be nice to the API
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  return statuses;
}

// Extract links from HTML content using regex
function extractLinksFromHtml(html) {
  const links = [];
  const regex = /href="([^"]+)"/g;
  let match;
  while ((match = regex.exec(html)) !== null) {
    links.push(match[1]);
  }
  return links;
}

// Parse deepakness.com URL and extract type (blog/raw) and slug
function parseDeepakNessUrl(url) {
  try {
    const parsed = new URL(url);
    if (parsed.hostname !== BLOG_DOMAIN && parsed.hostname !== `www.${BLOG_DOMAIN}`) {
      return null;
    }

    // Match /blog/slug or /raw/slug
    const match = parsed.pathname.match(/^\/(blog|raw)\/([^/]+)\/?$/);
    if (match) {
      return { type: match[1], slug: match[2] };
    }
    return null;
  } catch {
    return null;
  }
}

// Find markdown file for a given type and slug
function findMarkdownFile(type, slug) {
  const possiblePaths = [
    path.join(CONTENT_DIR, type, slug, 'index.md'),
    path.join(CONTENT_DIR, type, `${slug}.md`),
  ];

  for (const filePath of possiblePaths) {
    if (fs.existsSync(filePath)) {
      return filePath;
    }
  }
  return null;
}

// Check if frontmatter already has mastodon field
function hasMastodonField(content) {
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
  if (!frontmatterMatch) return false;
  return /^mastodon:/m.test(frontmatterMatch[1]);
}

// Insert mastodon field into frontmatter
function insertMastodonField(content, mastodonUrl) {
  // Insert after the opening ---
  return content.replace(/^---\n/, `---\nmastodon: "${mastodonUrl}"\n`);
}

// Scan content directories and find files (optionally only those missing mastodon field)
function findContentFiles(onlyMissing = true) {
  const files = [];
  const contentTypes = ['blog', 'raw'];

  for (const type of contentTypes) {
    const typeDir = path.join(CONTENT_DIR, type);
    if (!fs.existsSync(typeDir)) continue;

    const entries = fs.readdirSync(typeDir, { withFileTypes: true });
    for (const entry of entries) {
      let filePath;
      if (entry.isDirectory()) {
        filePath = path.join(typeDir, entry.name, 'index.md');
      } else if (entry.name.endsWith('.md')) {
        filePath = path.join(typeDir, entry.name);
      } else {
        continue;
      }

      if (!fs.existsSync(filePath)) continue;

      const content = fs.readFileSync(filePath, 'utf-8');
      const hasMastodon = hasMastodonField(content);

      if (!onlyMissing || !hasMastodon) {
        const slug = entry.isDirectory() ? entry.name : entry.name.replace('.md', '');
        files.push({ type, slug, filePath, hasMastodon });
      }
    }
  }

  return files;
}

// Main function
async function main() {
  console.log('\n🐘 Syncing Mastodon links to markdown files...\n');
  if (DRY_RUN) {
    console.log('🔍 DRY RUN MODE - No files will be modified\n');
  }
  if (FORCE) {
    console.log('🔄 FORCE MODE - Will overwrite existing mastodon fields\n');
  }

  // First, check which files need updating
  console.log('Scanning content files...');
  const filesToUpdate = findContentFiles(!FORCE);
  const label = FORCE ? 'files to check' : 'files missing mastodon field';
  console.log(`Found ${filesToUpdate.length} ${label}\n`);

  if (filesToUpdate.length === 0) {
    console.log('✨ All files already have mastodon field. Nothing to do!\n');
    return;
  }

  // Create a set for quick lookup
  const updateSet = new Set(filesToUpdate.map(f => `${f.type}/${f.slug}`));

  // Fetch account ID
  console.log(`Looking up account @${MASTODON_USERNAME}...`);
  const accountId = await fetchAccountId();
  console.log(`Found account ID: ${accountId}\n`);

  // Fetch all statuses
  console.log('Fetching all statuses...');
  const statuses = await fetchAllStatuses(accountId);
  console.log(`Found ${statuses.length} statuses\n`);

  // Build map of blog URLs to mastodon URLs (earliest status per slug)
  const matchMap = new Map(); // key: "type/slug", value: match object
  for (const status of statuses) {
    if (!status.content) continue;

    const links = extractLinksFromHtml(status.content);
    for (const link of links) {
      const parsed = parseDeepakNessUrl(link);
      if (parsed) {
        const key = `${parsed.type}/${parsed.slug}`;
        // Always overwrite to keep the earliest (last in reverse-chronological order)
        matchMap.set(key, {
          type: parsed.type,
          slug: parsed.slug,
          mastodonUrl: status.url,
          blogUrl: link,
        });
      }
    }
  }

  // Filter to only matches for files that need updating
  const matches = Array.from(matchMap.values()).filter(m => updateSet.has(`${m.type}/${m.slug}`));
  console.log(`Found ${matches.length} Mastodon posts for files needing update\n`);

  // Process matches
  let updated = 0;
  let skipped = 0;

  for (const match of matches) {
    const filePath = findMarkdownFile(match.type, match.slug);
    if (!filePath) continue;

    const relativePath = path.relative(ROOT_DIR, filePath);
    const content = fs.readFileSync(filePath, 'utf-8');

    // Check if file already has this exact mastodon URL (skip if same)
    const existingMatch = content.match(/^mastodon:\s*"([^"]+)"/m);
    if (existingMatch && existingMatch[1] === match.mastodonUrl) {
      console.log(`⏭️  Already correct: ${relativePath}`);
      skipped++;
      continue;
    }

    // Remove existing mastodon field if present (for --force)
    let newContent = content.replace(/^mastodon:\s*"[^"]+"\n/m, '');
    newContent = insertMastodonField(newContent, match.mastodonUrl);

    if (DRY_RUN) {
      console.log(`📝 Would update: ${relativePath}`);
      console.log(`   Mastodon URL: ${match.mastodonUrl}`);
    } else {
      fs.writeFileSync(filePath, newContent);
      console.log(`✅ Updated: ${relativePath}`);
    }
    updated++;
  }

  // Files that had no matching Mastodon post
  const notFound = filesToUpdate.length - updated - skipped;

  console.log(`\n✨ Done!`);
  console.log(`   Updated: ${updated}`);
  if (skipped > 0) {
    console.log(`   Already correct: ${skipped}`);
  }
  console.log(`   No Mastodon post found: ${notFound}\n`);
}

main().catch(error => {
  console.error('❌ Error:', error.message);
  process.exit(1);
});

The above Node.js script uses public Mastodon API endpoints that do not require authentication, so you don't even need an API key. It uses fs, path, url, and fetch libraries which are Node.js built-ins.

When I run the script in my terminal by running either npm run mastodon or node scripts/mastodon.js command in my terminal:

  1. Scans my local markdown files in content/blog/ and content/raw/ folders, identifies which ones are missing a mastodon: field in their YAML frontmatter.
  2. Fetches all posts from the @deepakness Mastodon account via the public API by paginating through all posts I have.
  3. Matches posts to blog entries by scanning each Mastodon post's HTML content for links to deepakness.com/blog/[slug] or deepakness.com/raw/[slug], and then maps those back to the related local markdown files.
  4. Updates the frontmatter of each matched markdown file by inserting a mastodon: "https://mastodon.social/..." field.

The script also has two optional flags:

  • --dry-run shows what would change without writing anything
  • --force overwrites existing mastodon: fields instead of skipping files that already have one

Also, when multiple Mastodon posts link to the same blog post, it keeps the earliest one (since posts are fetched in reverse-chronological order, the last match per slug is fetched).

Most probably, you won't be able to directly use this script as it's specific to how my 11ty blog is structured. But you can give the script to any LLM, provide info on how your static blog is structured, and ask to provide the modified version of the script.

I manually run the script every few days, so you won't see the "Comment on Mastodon" link on new posts right away – but it shows up soon enough.

Some links on this page are affiliate links. If you buy through them, I may earn a small commission at no extra cost to you. However, this does not affect my opinions.

Webmentions

What’s this?